From d2d7a1d689939a0e38add697d99bb8b8181a472d Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 15 Mar 2024 10:00:51 -0700 Subject: [PATCH 001/143] Add core module. --- Jetcaster/app/build.gradle.kts | 10 +--- .../example/jetcaster/JetcasterApplication.kt | 1 + .../com/example/jetcaster/ui/home/Home.kt | 6 +- .../jetcaster/ui/home/HomeViewModel.kt | 16 +++--- .../example/jetcaster/ui/home/PreviewData.kt | 12 ++-- .../ui/home/category/PodcastCategory.kt | 13 +++-- .../jetcaster/ui/home/discover/Discover.kt | 2 +- .../jetcaster/ui/home/library/Library.kt | 2 +- .../jetcaster/ui/player/PlayerViewModel.kt | 6 +- Jetcaster/build.gradle.kts | 5 +- Jetcaster/core/.gitignore | 1 + Jetcaster/core/build.gradle.kts | 57 +++++++++++++++++++ Jetcaster/core/consumer-rules.pro | 0 Jetcaster/core/proguard-rules.pro | 21 +++++++ Jetcaster/core/src/main/AndroidManifest.xml | 4 ++ .../data/database}/DateTimeTypeConverters.kt | 2 +- .../core/data/database}/JetcasterDatabase.kt | 18 ++++-- .../core/data/database/dao}/BaseDao.kt | 2 +- .../core/data/database/dao}/CategoriesDao.kt | 4 +- .../core/data/database/dao}/EpisodesDao.kt | 6 +- .../database/dao}/PodcastCategoryEntryDao.kt | 4 +- .../database/dao}/PodcastFollowedEntryDao.kt | 4 +- .../core/data/database/dao}/PodcastsDao.kt | 6 +- .../database/dao}/TransactionRunnerDao.kt | 2 +- .../core/data/database/model}/Category.kt | 2 +- .../core/data/database/model}/Episode.kt | 2 +- .../data/database/model}/EpisodeToPodcast.kt | 2 +- .../core/data/database/model}/Podcast.kt | 2 +- .../database/model}/PodcastCategoryEntry.kt | 2 +- .../database/model}/PodcastFollowedEntry.kt | 12 ++-- .../database/model}/PodcastWithExtraInfo.kt | 2 +- .../example/jetcaster/core/data/di}/Graph.kt | 19 ++++--- .../jetcaster/core/data/network}/Feeds.kt | 2 +- .../core/data/network}/OkHttpExtensions.kt | 6 +- .../core/data/network}/PodcastFetcher.kt | 5 +- .../core/data/repository}/CategoryStore.kt | 14 +++-- .../core/data/repository}/EpisodeStore.kt | 6 +- .../core/data/repository}/PodcastStore.kt | 11 ++-- .../data/repository}/PodcastsRepository.kt | 8 ++- Jetcaster/gradle/libs.versions.toml | 1 + Jetcaster/settings.gradle.kts | 3 +- 41 files changed, 202 insertions(+), 101 deletions(-) create mode 100644 Jetcaster/core/.gitignore create mode 100644 Jetcaster/core/build.gradle.kts create mode 100644 Jetcaster/core/consumer-rules.pro create mode 100644 Jetcaster/core/proguard-rules.pro create mode 100644 Jetcaster/core/src/main/AndroidManifest.xml rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database}/DateTimeTypeConverters.kt (97%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database}/JetcasterDatabase.kt (63%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database/dao}/BaseDao.kt (95%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database/dao}/CategoriesDao.kt (92%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database/dao}/EpisodesDao.kt (90%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database/dao}/PodcastCategoryEntryDao.kt (86%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database/dao}/PodcastFollowedEntryDao.kt (90%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database/dao}/PodcastsDao.kt (94%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data/room => core/src/main/java/com/example/jetcaster/core/data/database/dao}/TransactionRunnerDao.kt (95%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/database/model}/Category.kt (94%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/database/model}/Episode.kt (97%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/database/model}/EpisodeToPodcast.kt (96%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/database/model}/Podcast.kt (95%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/database/model}/PodcastCategoryEntry.kt (96%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/database/model}/PodcastFollowedEntry.kt (82%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/database/model}/PodcastWithExtraInfo.kt (96%) rename Jetcaster/{app/src/main/java/com/example/jetcaster => core/src/main/java/com/example/jetcaster/core/data/di}/Graph.kt (85%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/network}/Feeds.kt (97%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/network}/OkHttpExtensions.kt (97%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/network}/PodcastFetcher.kt (96%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/repository}/CategoryStore.kt (81%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/repository}/EpisodeStore.kt (86%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/repository}/PodcastStore.kt (84%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/data => core/src/main/java/com/example/jetcaster/core/data/repository}/PodcastsRepository.kt (89%) diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index 0344801aa8..f630542b43 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -117,15 +117,7 @@ dependencies { implementation(libs.coil.kt.compose) - implementation(libs.okhttp3) - implementation(libs.okhttp.logging) + implementation(project(":core")) - implementation(libs.rometools.rome) - implementation(libs.rometools.modules) - - implementation(libs.androidx.room.runtime) - implementation(libs.androidx.room.ktx) - - ksp(libs.androidx.room.compiler) coreLibraryDesugaring(libs.core.jdk.desugaring) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt index 42b4d133ba..1493a62e3f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt @@ -19,6 +19,7 @@ package com.example.jetcaster import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory +import com.example.jetcaster.core.data.di.Graph /** * Application which sets up our dependency [Graph] with a context. diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 571648e9cc..b7db0741ad 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -75,9 +75,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.jetcaster.R -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.EpisodeToPodcast -import com.example.jetcaster.data.PodcastWithExtraInfo +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.ui.home.category.PodcastCategoryViewState import com.example.jetcaster.ui.home.discover.DiscoverViewState import com.example.jetcaster.ui.home.discover.discoverItems diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 9240c54076..c3e7af714d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -18,14 +18,14 @@ package com.example.jetcaster.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.Graph -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.CategoryStore -import com.example.jetcaster.data.EpisodeStore -import com.example.jetcaster.data.EpisodeToPodcast -import com.example.jetcaster.data.PodcastStore -import com.example.jetcaster.data.PodcastWithExtraInfo -import com.example.jetcaster.data.PodcastsRepository +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.ui.home.category.PodcastCategoryViewState import com.example.jetcaster.ui.home.discover.DiscoverViewState import com.example.jetcaster.util.combine diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index 3a6d96ecc3..c5b2fb2914 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -16,14 +16,14 @@ package com.example.jetcaster.ui.home -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.EpisodeToPodcast -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastWithExtraInfo +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 kotlinx.collections.immutable.toPersistentList import java.time.OffsetDateTime import java.time.ZoneOffset -import kotlinx.collections.immutable.toPersistentList val PreviewCategories = listOf( Category(name = "Crime"), diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index e8f3528724..321d72e3f8 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -68,10 +68,10 @@ import androidx.constraintlayout.compose.Dimension.Companion.preferredWrapConten import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.EpisodeToPodcast -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastWithExtraInfo +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.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.theme.JetcasterTheme @@ -218,15 +218,16 @@ fun EpisodeListItem( ) CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + val duration = episode.duration Text( text = when { - episode.duration != null -> { + 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), - episode.duration.toMinutes().toInt() + duration.toMinutes().toInt() ) } // Otherwise we just use the date diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 638ea2fb24..817d620b70 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -30,7 +30,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.example.jetcaster.data.Category +import com.example.jetcaster.core.data.database.model.Category import com.example.jetcaster.ui.home.category.PodcastCategoryViewState import com.example.jetcaster.ui.home.category.podcastCategory import com.example.jetcaster.ui.theme.Keyline1 diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index 8f12f6a591..490ea28c9b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -19,7 +19,7 @@ package com.example.jetcaster.ui.home.library import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.ui.Modifier -import com.example.jetcaster.data.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.ui.home.category.EpisodeListItem fun LazyListScope.libraryItems( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 73265ee436..177104f714 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -26,9 +26,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner -import com.example.jetcaster.Graph -import com.example.jetcaster.data.EpisodeStore -import com.example.jetcaster.data.PodcastStore +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore import java.time.Duration import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch diff --git a/Jetcaster/build.gradle.kts b/Jetcaster/build.gradle.kts index c83db36f52..c9c401efd5 100644 --- a/Jetcaster/build.gradle.kts +++ b/Jetcaster/build.gradle.kts @@ -15,8 +15,11 @@ */ plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.gradle.versions) alias(libs.plugins.version.catalog.update) } -apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") \ No newline at end of file +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") diff --git a/Jetcaster/core/.gitignore b/Jetcaster/core/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/build.gradle.kts b/Jetcaster/core/build.gradle.kts new file mode 100644 index 0000000000..b4d57239f4 --- /dev/null +++ b/Jetcaster/core/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) +} + +// TODO(chris): Set up convention plugin +android { + namespace = "com.example.jetcaster.core" + 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 + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.runtime) + + implementation(libs.coil.kt.compose) + + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + + implementation(libs.okhttp3) + implementation(libs.okhttp.logging) + + 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) +} diff --git a/Jetcaster/core/consumer-rules.pro b/Jetcaster/core/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/proguard-rules.pro b/Jetcaster/core/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/src/main/AndroidManifest.xml b/Jetcaster/core/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt similarity index 97% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt index 4b4fb5d0a9..0199678c4c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database import androidx.room.TypeConverter import java.time.Duration diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt similarity index 63% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt index cc4f2a24e7..43cae1daef 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt @@ -14,16 +14,22 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastCategoryEntry -import com.example.jetcaster.data.PodcastFollowedEntry +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 +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 /** * The [RoomDatabase] we use in this app. diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/BaseDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt similarity index 95% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/BaseDao.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt index 4eac0f395b..eca987c370 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/BaseDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Delete import androidx.room.Insert diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt similarity index 92% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt index 7f851339fa..f9b36601cb 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt @@ -14,11 +14,11 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao import androidx.room.Query -import com.example.jetcaster.data.Category +import com.example.jetcaster.core.data.database.model.Category import kotlinx.coroutines.flow.Flow /** diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt similarity index 90% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt index 52701d6298..5ba874532c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import kotlinx.coroutines.flow.Flow /** diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt similarity index 86% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt index 681c828125..5291649e34 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao -import com.example.jetcaster.data.PodcastCategoryEntry +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry /** * [Room] DAO for [PodcastCategoryEntry] related operations. diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt similarity index 90% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt index 69c8dbf0d9..0816cc05e7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt @@ -14,11 +14,11 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao import androidx.room.Query -import com.example.jetcaster.data.PodcastFollowedEntry +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry @Dao abstract class PodcastFollowedEntryDao : BaseDao { diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt similarity index 94% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt index 7c04dcd005..9a5426e849 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastWithExtraInfo +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import kotlinx.coroutines.flow.Flow /** diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt similarity index 95% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt index e7c51cad4f..6f4b0c49e6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao import androidx.room.Ignore diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt similarity index 94% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt index 3279017b3a..4dff2871ef 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt similarity index 97% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt index b5dc88b94d..6a035d9646 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt index 4f87ba9e05..7945f20316 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.room.Embedded import androidx.room.Ignore diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt similarity index 95% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt index 969908f14a..1d86f31f91 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt index 394af2fca8..3c2c67878d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt similarity index 82% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt index 0be51c77bc..fbff807f6b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo @@ -27,11 +27,11 @@ import androidx.room.PrimaryKey tableName = "podcast_followed_entries", foreignKeys = [ ForeignKey( - entity = Podcast::class, - parentColumns = ["uri"], - childColumns = ["podcast_uri"], - onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE + entity = Podcast::class, + parentColumns = ["uri"], + childColumns = ["podcast_uri"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE ) ], indices = [ diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt index 200e6248c2..8794a46e47 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.room.ColumnInfo import androidx.room.Embedded diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt similarity index 85% rename from Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt index 3d831558a7..5a4affea58 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt @@ -14,24 +14,25 @@ * limitations under the License. */ -package com.example.jetcaster +package com.example.jetcaster.core.data.di import android.content.Context import androidx.room.Room -import com.example.jetcaster.data.CategoryStore -import com.example.jetcaster.data.EpisodeStore -import com.example.jetcaster.data.PodcastStore -import com.example.jetcaster.data.PodcastsFetcher -import com.example.jetcaster.data.PodcastsRepository -import com.example.jetcaster.data.room.JetcasterDatabase -import com.example.jetcaster.data.room.TransactionRunner +import com.example.jetcaster.core.BuildConfig +import com.example.jetcaster.core.data.database.JetcasterDatabase +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.network.PodcastsFetcher +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.rometools.rome.io.SyndFeedInput -import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.LoggingEventListener +import java.io.File /** * A very simple global singleton dependency graph. diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt similarity index 97% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt index 651ebf423f..ead4bbb3e4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network /** * A hand selected list of feeds URLs used for the purposes of displaying real information diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt similarity index 97% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt index e9a516f0ed..3af8cc6160 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt @@ -14,15 +14,15 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network -import java.io.IOException -import kotlin.coroutines.resumeWithException import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.Response import okhttp3.internal.closeQuietly +import java.io.IOException +import kotlin.coroutines.resumeWithException /** * Suspending wrapper around an OkHttp [Call], using [Call.enqueue]. diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt index a13cdca901..eaf619487d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt @@ -14,9 +14,12 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network import coil.network.HttpException +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.SyndEntry diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt similarity index 81% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt index cabf7e9e29..f3a153968b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt @@ -14,12 +14,16 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.repository -import com.example.jetcaster.data.room.CategoriesDao -import com.example.jetcaster.data.room.EpisodesDao -import com.example.jetcaster.data.room.PodcastCategoryEntryDao -import com.example.jetcaster.data.room.PodcastsDao +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +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.PodcastCategoryEntry import kotlinx.coroutines.flow.Flow /** diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt similarity index 86% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt index d60fa6e7c4..53b53f0a85 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt @@ -14,9 +14,11 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.repository -import com.example.jetcaster.data.room.EpisodesDao +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.model.Episode import kotlinx.coroutines.flow.Flow /** diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt similarity index 84% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt index b9ace6b52e..b57a7d1f1f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -14,11 +14,14 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.repository -import com.example.jetcaster.data.room.PodcastFollowedEntryDao -import com.example.jetcaster.data.room.PodcastsDao -import com.example.jetcaster.data.room.TransactionRunner +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +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.Podcast +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry import kotlinx.coroutines.flow.Flow /** diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt similarity index 89% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt index f078b12f85..7b48603f4f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt @@ -14,13 +14,15 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.repository -import com.example.jetcaster.data.room.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 com.example.jetcaster.core.data.database.dao.TransactionRunner import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index 502cf6f9aa..d6426fb00c 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -134,6 +134,7 @@ rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } diff --git a/Jetcaster/settings.gradle.kts b/Jetcaster/settings.gradle.kts index a7c272a185..dd4640e50d 100644 --- a/Jetcaster/settings.gradle.kts +++ b/Jetcaster/settings.gradle.kts @@ -35,5 +35,4 @@ dependencyResolutionManagement { } } rootProject.name = "Jetcaster" -include(":app") - +include(":app", ":core") From 0087b43f36b809c02940c639881c217115c0b828 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 15 Mar 2024 15:17:26 -0700 Subject: [PATCH 002/143] Add designsystem module. --- Jetcaster/app/build.gradle.kts | 2 + .../com/example/jetcaster/ui/theme/Theme.kt | 496 ++++++++++++++++++ .../com/example/jetcaster/ui/theme/Type.kt | 113 +++- Jetcaster/designsystem/.gitignore | 1 + Jetcaster/designsystem/build.gradle.kts | 38 ++ Jetcaster/designsystem/consumer-rules.pro | 0 Jetcaster/designsystem/proguard-rules.pro | 21 + .../designsystem/src/main/AndroidManifest.xml | 4 + .../jetcaster/designsystem/theme/Color.kt | 218 ++++++++ .../designsystem/theme/Typography.kt | 13 + .../src/main/res/font/montserrat_light.ttf | Bin .../src/main/res/font/montserrat_medium.ttf | Bin .../src/main/res/font/montserrat_regular.ttf | Bin .../src/main/res/font/montserrat_semibold.ttf | Bin Jetcaster/gradle/libs.versions.toml | 2 + Jetcaster/settings.gradle.kts | 2 +- 16 files changed, 899 insertions(+), 11 deletions(-) create mode 100644 Jetcaster/designsystem/.gitignore create mode 100644 Jetcaster/designsystem/build.gradle.kts create mode 100644 Jetcaster/designsystem/consumer-rules.pro create mode 100644 Jetcaster/designsystem/proguard-rules.pro create mode 100644 Jetcaster/designsystem/src/main/AndroidManifest.xml create mode 100644 Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt create mode 100644 Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt rename Jetcaster/{app => designsystem}/src/main/res/font/montserrat_light.ttf (100%) rename Jetcaster/{app => designsystem}/src/main/res/font/montserrat_medium.ttf (100%) rename Jetcaster/{app => designsystem}/src/main/res/font/montserrat_regular.ttf (100%) rename Jetcaster/{app => designsystem}/src/main/res/font/montserrat_semibold.ttf (100%) diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index f630542b43..46d0d4c8b4 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -101,6 +101,7 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.materialWindow) implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.tooling.preview) @@ -118,6 +119,7 @@ dependencies { implementation(libs.coil.kt.compose) implementation(project(":core")) + implementation(project(":designsystem")) coreLibraryDesugaring(libs.core.jdk.desugaring) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt index 477332fe46..a6c728a5eb 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt @@ -16,8 +16,232 @@ package com.example.jetcaster.ui.theme +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme +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.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +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 @Composable fun JetcasterTheme( @@ -30,3 +254,275 @@ fun JetcasterTheme( content = content ) } + +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 +) + +@Composable +fun JetcasterThemeM3( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + 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 + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + androidx.compose.material3.MaterialTheme( + colorScheme = colorScheme, + typography = JetcasterTypographyM3, + content = content + ) +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt index 1c407e52cb..0fcabdd969 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt @@ -18,18 +18,9 @@ package com.example.jetcaster.ui.theme import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import com.example.jetcaster.R - -private 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) -) +import com.example.jetcaster.designsystem.theme.Montserrat val JetcasterTypography = Typography( h1 = TextStyle( @@ -120,3 +111,105 @@ val JetcasterTypography = Typography( letterSpacing = 1.sp ) ) + +val JetcasterTypographyM3 = androidx.compose.material3.Typography( + displayLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 57.sp, + fontWeight = FontWeight.W400, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = Montserrat, + 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 + ), +) diff --git a/Jetcaster/designsystem/.gitignore b/Jetcaster/designsystem/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/designsystem/build.gradle.kts b/Jetcaster/designsystem/build.gradle.kts new file mode 100644 index 0000000000..566a565b39 --- /dev/null +++ b/Jetcaster/designsystem/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +// TODO(chris): Set up convention plugin +android { + namespace = "com.example.jetcaster.designsystem" + 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 { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.text) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) +} diff --git a/Jetcaster/designsystem/consumer-rules.pro b/Jetcaster/designsystem/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/designsystem/proguard-rules.pro b/Jetcaster/designsystem/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/designsystem/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/designsystem/src/main/AndroidManifest.xml b/Jetcaster/designsystem/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/designsystem/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt new file mode 100644 index 0000000000..1df38d7625 --- /dev/null +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt @@ -0,0 +1,218 @@ +package com.example.jetcaster.designsystem.theme +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF885200) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFFFAC46) +val onPrimaryContainerLight = Color(0xFF482900) +val secondaryLight = Color(0xFF7A5817) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFFFD798) +val onSecondaryContainerLight = Color(0xFF5C3F00) +val tertiaryLight = Color(0xFF994700) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFF801F) +val onTertiaryContainerLight = Color(0xFF2D1000) +val errorLight = Color(0xFFA4384A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFF87889) +val onErrorContainerLight = Color(0xFF32000A) +val backgroundLight = Color(0xFFFFF8F4) +val onBackgroundLight = Color(0xFF221A11) +val surfaceLight = Color(0xFFFFF8F4) +val onSurfaceLight = Color(0xFF221A11) +val surfaceVariantLight = Color(0xFFF7DEC8) +val onSurfaceVariantLight = Color(0xFF544434) +val outlineLight = Color(0xFF877461) +val outlineVariantLight = Color(0xFFDAC3AD) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF382F25) +val inverseOnSurfaceLight = Color(0xFFFFEEDF) +val inversePrimaryLight = Color(0xFFFFB868) +val surfaceDimLight = Color(0xFFE8D7C9) +val surfaceBrightLight = Color(0xFFFFF8F4) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFFFF1E6) +val surfaceContainerLight = Color(0xFFFCEBDC) +val surfaceContainerHighLight = Color(0xFFF6E5D7) +val surfaceContainerHighestLight = Color(0xFFF1E0D1) + +val primaryLightMediumContrast = Color(0xFF623A00) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFFA76600) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF5A3D00) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF936E2B) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF6F3100) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFFBC5800) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF7F1B30) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFC14E5F) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFFF8F4) +val onBackgroundLightMediumContrast = Color(0xFF221A11) +val surfaceLightMediumContrast = Color(0xFFFFF8F4) +val onSurfaceLightMediumContrast = Color(0xFF221A11) +val surfaceVariantLightMediumContrast = Color(0xFFF7DEC8) +val onSurfaceVariantLightMediumContrast = Color(0xFF504030) +val outlineLightMediumContrast = Color(0xFF6E5C4A) +val outlineVariantLightMediumContrast = Color(0xFF8B7765) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF382F25) +val inverseOnSurfaceLightMediumContrast = Color(0xFFFFEEDF) +val inversePrimaryLightMediumContrast = Color(0xFFFFB868) +val surfaceDimLightMediumContrast = Color(0xFFE8D7C9) +val surfaceBrightLightMediumContrast = Color(0xFFFFF8F4) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFFFF1E6) +val surfaceContainerLightMediumContrast = Color(0xFFFCEBDC) +val surfaceContainerHighLightMediumContrast = Color(0xFFF6E5D7) +val surfaceContainerHighestLightMediumContrast = Color(0xFFF1E0D1) + +val primaryLightHighContrast = Color(0xFF351D00) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF623A00) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF301F00) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF5A3D00) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF3C1800) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF6F3100) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF4C0014) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF7F1B30) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFFF8F4) +val onBackgroundLightHighContrast = Color(0xFF221A11) +val surfaceLightHighContrast = Color(0xFFFFF8F4) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFF7DEC8) +val onSurfaceVariantLightHighContrast = Color(0xFF2E2113) +val outlineLightHighContrast = Color(0xFF504030) +val outlineVariantLightHighContrast = Color(0xFF504030) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF382F25) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFFFE8D4) +val surfaceDimLightHighContrast = Color(0xFFE8D7C9) +val surfaceBrightLightHighContrast = Color(0xFFFFF8F4) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFFFF1E6) +val surfaceContainerLightHighContrast = Color(0xFFFCEBDC) +val surfaceContainerHighLightHighContrast = Color(0xFFF6E5D7) +val surfaceContainerHighestLightHighContrast = Color(0xFFF1E0D1) + +val primaryDark = Color(0xFFFFCF9E) +val onPrimaryDark = Color(0xFF482900) +val primaryContainerDark = Color(0xFFF79900) +val onPrimaryContainerDark = Color(0xFF371E00) +val secondaryDark = Color(0xFFFFFEFF) +val onSecondaryDark = Color(0xFF422C00) +val secondaryContainerDark = Color(0xFFFBCC80) +val onSecondaryContainerDark = Color(0xFF553A00) +val tertiaryDark = Color(0xFFFFB68B) +val onTertiaryDark = Color(0xFF522300) +val tertiaryContainerDark = Color(0xFFE76E00) +val onTertiaryContainerDark = Color(0xFF000000) +val errorDark = Color(0xFFFFB2B9) +val onErrorDark = Color(0xFF65041F) +val errorContainerDark = Color(0xFFC14E5F) +val onErrorContainerDark = Color(0xFFFFFFFF) +val backgroundDark = Color(0xFF1A120A) +val onBackgroundDark = Color(0xFFF1E0D1) +val surfaceDark = Color(0xFF1A120A) +val onSurfaceDark = Color(0xFFF1E0D1) +val surfaceVariantDark = Color(0xFF544434) +val onSurfaceVariantDark = Color(0xFFDAC3AD) +val outlineDark = Color(0xFFA28D7A) +val outlineVariantDark = Color(0xFF544434) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFF1E0D1) +val inverseOnSurfaceDark = Color(0xFF382F25) +val inversePrimaryDark = Color(0xFF885200) +val surfaceDimDark = Color(0xFF1A120A) +val surfaceBrightDark = Color(0xFF42372D) +val surfaceContainerLowestDark = Color(0xFF140D06) +val surfaceContainerLowDark = Color(0xFF221A11) +val surfaceContainerDark = Color(0xFF271E15) +val surfaceContainerHighDark = Color(0xFF32281F) +val surfaceContainerHighestDark = Color(0xFF3D3329) + +val primaryDarkMediumContrast = Color(0xFFFFCF9E) +val onPrimaryDarkMediumContrast = Color(0xFF351D00) +val primaryContainerDarkMediumContrast = Color(0xFFF79900) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFFFFEFF) +val onSecondaryDarkMediumContrast = Color(0xFF422C00) +val secondaryContainerDarkMediumContrast = Color(0xFFFBCC80) +val onSecondaryContainerDarkMediumContrast = Color(0xFF2C1C00) +val tertiaryDarkMediumContrast = Color(0xFFFFBC95) +val onTertiaryDarkMediumContrast = Color(0xFF2A0E00) +val tertiaryContainerDarkMediumContrast = Color(0xFFE76E00) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFB8BE) +val onErrorDarkMediumContrast = Color(0xFF36000C) +val errorContainerDarkMediumContrast = Color(0xFFE5697A) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF1A120A) +val onBackgroundDarkMediumContrast = Color(0xFFF1E0D1) +val surfaceDarkMediumContrast = Color(0xFF1A120A) +val onSurfaceDarkMediumContrast = Color(0xFFFFFAF8) +val surfaceVariantDarkMediumContrast = Color(0xFF544434) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDEC7B1) +val outlineDarkMediumContrast = Color(0xFFB59F8B) +val outlineVariantDarkMediumContrast = Color(0xFF93806D) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFF1E0D1) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF32281F) +val inversePrimaryDarkMediumContrast = Color(0xFF693E00) +val surfaceDimDarkMediumContrast = Color(0xFF1A120A) +val surfaceBrightDarkMediumContrast = Color(0xFF42372D) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF140D06) +val surfaceContainerLowDarkMediumContrast = Color(0xFF221A11) +val surfaceContainerDarkMediumContrast = Color(0xFF271E15) +val surfaceContainerHighDarkMediumContrast = Color(0xFF32281F) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3D3329) + +val primaryDarkHighContrast = Color(0xFFFFFAF8) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFFFBE76) +val onPrimaryContainerDarkHighContrast = Color(0xFF000000) +val secondaryDarkHighContrast = Color(0xFFFFFEFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFFBCC80) +val onSecondaryContainerDarkHighContrast = Color(0xFF000000) +val tertiaryDarkHighContrast = Color(0xFFFFFAF8) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFFFBC95) +val onTertiaryContainerDarkHighContrast = Color(0xFF000000) +val errorDarkHighContrast = Color(0xFFFFF9F9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFB8BE) +val onErrorContainerDarkHighContrast = Color(0xFF000000) +val backgroundDarkHighContrast = Color(0xFF1A120A) +val onBackgroundDarkHighContrast = Color(0xFFF1E0D1) +val surfaceDarkHighContrast = Color(0xFF1A120A) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF544434) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFAF8) +val outlineDarkHighContrast = Color(0xFFDEC7B1) +val outlineVariantDarkHighContrast = Color(0xFFDEC7B1) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFF1E0D1) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF3F2400) +val surfaceDimDarkHighContrast = Color(0xFF1A120A) +val surfaceBrightDarkHighContrast = Color(0xFF42372D) +val surfaceContainerLowestDarkHighContrast = Color(0xFF140D06) +val surfaceContainerLowDarkHighContrast = Color(0xFF221A11) +val surfaceContainerDarkHighContrast = Color(0xFF271E15) +val surfaceContainerHighDarkHighContrast = Color(0xFF32281F) +val surfaceContainerHighestDarkHighContrast = Color(0xFF3D3329) diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt new file mode 100644 index 0000000000..03c7302d50 --- /dev/null +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt @@ -0,0 +1,13 @@ +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.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) +) diff --git a/Jetcaster/app/src/main/res/font/montserrat_light.ttf b/Jetcaster/designsystem/src/main/res/font/montserrat_light.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_light.ttf rename to Jetcaster/designsystem/src/main/res/font/montserrat_light.ttf diff --git a/Jetcaster/app/src/main/res/font/montserrat_medium.ttf b/Jetcaster/designsystem/src/main/res/font/montserrat_medium.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_medium.ttf rename to Jetcaster/designsystem/src/main/res/font/montserrat_medium.ttf diff --git a/Jetcaster/app/src/main/res/font/montserrat_regular.ttf b/Jetcaster/designsystem/src/main/res/font/montserrat_regular.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_regular.ttf rename to Jetcaster/designsystem/src/main/res/font/montserrat_regular.ttf diff --git a/Jetcaster/app/src/main/res/font/montserrat_semibold.ttf b/Jetcaster/designsystem/src/main/res/font/montserrat_semibold.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_semibold.ttf rename to Jetcaster/designsystem/src/main/res/font/montserrat_semibold.ttf diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index d6426fb00c..402d8fdfb2 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -77,9 +77,11 @@ androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } diff --git a/Jetcaster/settings.gradle.kts b/Jetcaster/settings.gradle.kts index dd4640e50d..966fed261f 100644 --- a/Jetcaster/settings.gradle.kts +++ b/Jetcaster/settings.gradle.kts @@ -35,4 +35,4 @@ dependencyResolutionManagement { } } rootProject.name = "Jetcaster" -include(":app", ":core") +include(":app", ":core", ":designsystem") From 31265722d43e31caa503d237540c32887e66c979 Mon Sep 17 00:00:00 2001 From: arriolac Date: Fri, 15 Mar 2024 22:49:50 +0000 Subject: [PATCH 003/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jetcaster/ui/home/HomeViewModel.kt | 6 ++--- .../example/jetcaster/ui/home/PreviewData.kt | 2 +- .../core/data/database/JetcasterDatabase.kt | 10 ++++---- .../database/model/PodcastFollowedEntry.kt | 10 ++++---- .../example/jetcaster/core/data/di/Graph.kt | 2 +- .../core/data/network/OkHttpExtensions.kt | 4 ++-- .../core/data/repository/CategoryStore.kt | 4 ++-- .../core/data/repository/EpisodeStore.kt | 2 +- .../core/data/repository/PodcastStore.kt | 2 +- .../data/repository/PodcastsRepository.kt | 2 +- .../jetcaster/designsystem/theme/Color.kt | 16 +++++++++++++ .../designsystem/theme/Typography.kt | 24 +++++++++++++++---- 12 files changed, 58 insertions(+), 26 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index c3e7af714d..07317b592f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -18,13 +18,13 @@ package com.example.jetcaster.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.di.Graph 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.di.Graph import com.example.jetcaster.core.data.repository.CategoryStore import com.example.jetcaster.core.data.repository.EpisodeStore -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.repository.PodcastStore -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.ui.home.category.PodcastCategoryViewState import com.example.jetcaster.ui.home.discover.DiscoverViewState diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index c5b2fb2914..a0968fd164 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -21,9 +21,9 @@ 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 kotlinx.collections.immutable.toPersistentList import java.time.OffsetDateTime import java.time.ZoneOffset +import kotlinx.collections.immutable.toPersistentList val PreviewCategories = listOf( Category(name = "Crime"), diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt index 43cae1daef..ced5d408b0 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt @@ -19,17 +19,17 @@ 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.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 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. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt index fbff807f6b..420e68f38f 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt @@ -27,11 +27,11 @@ import androidx.room.PrimaryKey tableName = "podcast_followed_entries", foreignKeys = [ ForeignKey( - entity = Podcast::class, - parentColumns = ["uri"], - childColumns = ["podcast_uri"], - onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE + entity = Podcast::class, + parentColumns = ["uri"], + childColumns = ["podcast_uri"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE ) ], indices = [ diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt index 5a4affea58..30b6069fd6 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt @@ -27,12 +27,12 @@ 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.rometools.rome.io.SyndFeedInput +import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.LoggingEventListener -import java.io.File /** * A very simple global singleton dependency graph. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt index 3af8cc6160..147fed436e 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt @@ -16,13 +16,13 @@ package com.example.jetcaster.core.data.network +import java.io.IOException +import kotlin.coroutines.resumeWithException import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.Response import okhttp3.internal.closeQuietly -import java.io.IOException -import kotlin.coroutines.resumeWithException /** * Suspending wrapper around an OkHttp [Call], using [Call.enqueue]. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt index f3a153968b..20af8ee599 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt @@ -16,14 +16,14 @@ package com.example.jetcaster.core.data.repository -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo 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 /** diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt index 53b53f0a85..bc21bac561 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt @@ -16,9 +16,9 @@ package com.example.jetcaster.core.data.repository -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast 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 /** diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt index b57a7d1f1f..5d47decb4f 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -16,12 +16,12 @@ package com.example.jetcaster.core.data.repository -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo 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.Podcast import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import kotlinx.coroutines.flow.Flow /** diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt index 7b48603f4f..2458eb5a22 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt @@ -16,10 +16,10 @@ package com.example.jetcaster.core.data.repository +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 com.example.jetcaster.core.data.database.dao.TransactionRunner import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt index 1df38d7625..51ab6000bc 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt index 03c7302d50..bd9320cd6d 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt @@ -1,3 +1,19 @@ +/* + * 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 @@ -6,8 +22,8 @@ import androidx.compose.ui.text.font.FontWeight import com.example.jetcaster.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) + 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) ) From bef10b8c84365b45ae873513565f2cd4e088a51f Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 19 Mar 2024 12:55:35 +0900 Subject: [PATCH 004/143] add tv-module --- Jetcaster/gradle/libs.versions.toml | 29 ++++---- Jetcaster/settings.gradle.kts | 1 + Jetcaster/tv-app/.gitignore | 1 + Jetcaster/tv-app/build.gradle.kts | 65 ++++++++++++++++++ Jetcaster/tv-app/proguard-rules.pro | 21 ++++++ Jetcaster/tv-app/src/main/AndroidManifest.xml | 30 ++++++++ .../com/example/jetcaster/tv/MainActivity.kt | 48 +++++++++++++ .../example/jetcaster/tv/ui/theme/Color.kt | 11 +++ .../example/jetcaster/tv/ui/theme/Theme.kt | 34 +++++++++ .../com/example/jetcaster/tv/ui/theme/Type.kt | 36 ++++++++++ .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../tv-app/src/main/res/values/strings.xml | 3 + .../tv-app/src/main/res/values/themes.xml | 4 ++ 17 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 Jetcaster/tv-app/.gitignore create mode 100644 Jetcaster/tv-app/build.gradle.kts create mode 100644 Jetcaster/tv-app/proguard-rules.pro create mode 100644 Jetcaster/tv-app/src/main/AndroidManifest.xml create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt create mode 100644 Jetcaster/tv-app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 Jetcaster/tv-app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 Jetcaster/tv-app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 Jetcaster/tv-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 Jetcaster/tv-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 Jetcaster/tv-app/src/main/res/values/strings.xml create mode 100644 Jetcaster/tv-app/src/main/res/values/themes.xml diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index 402d8fdfb2..b35af8517b 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -4,15 +4,16 @@ ##### [versions] accompanist = "0.34.0" -androidGradlePlugin = "8.3.0" +androidGradlePlugin = "8.2.0" androidx-activity-compose = "1.8.2" androidx-appcompat = "1.6.1" -androidx-benchmark = "1.2.0" -androidx-benchmark-junit4 = "1.2.1" +androidx-benchmark = "1.2.3" +androidx-benchmark-junit4 = "1.2.3" androidx-compose-bom = "2024.02.02" androidx-constraintlayout = "1.0.1" androidx-corektx = "1.13.0-alpha05" androidx-glance = "1.0.0" +androidx-lifecycle-runtime = "2.7.0" androidx-lifecycle-compose = "2.7.0" androidx-lifecycle-runtime-compose = "2.7.0" androidx-navigation = "2.7.7" @@ -21,20 +22,22 @@ androidx-test = "1.5.0" androidx-test-espresso = "3.5.1" androidx-test-ext-junit = "1.1.5" androidx-test-ext-truth = "1.5.0" +androidx-tv-foundation = "1.0.0-alpha10" +androidx-tv-material = "1.0.0-alpha10" androidx-window = "1.3.0-alpha03" -androidxHiltNavigationCompose = "1.1.0" -androix-test-uiautomator = "2.2.0" -coil = "2.4.0" +androidxHiltNavigationCompose = "1.2.0" +androix-test-uiautomator = "2.3.0" +coil = "2.5.0" # @keep compileSdk = "34" compose-compiler = "1.5.4" coroutines = "1.8.0" google-maps = "18.2.0" gradle-versions = "0.51.0" -hilt = "2.48.1" -hiltExt = "1.1.0" +hilt = "2.49" +hiltExt = "1.2.0" # @pin When updating to AGP 7.4.0-alpha10 and up we can update this https://developer.android.com/studio/write/java8-support#library-desugaring-versions -jdkDesugar = "1.2.2" +jdkDesugar = "2.0.4" junit = "4.13.2" # @pin Update in conjuction with Compose Compiler kotlin = "1.9.20" @@ -44,10 +47,10 @@ maps-compose = "3.1.1" material = "1.11.0" # @keep minSdk = "21" -okhttp = "4.11.0" +okhttp = "4.12.0" robolectric = "4.9.2" rome = "1.18.0" -room = "2.6.0" +room = "2.6.1" secrets = "2.0.1" # @keep targetSdk = "33" @@ -93,7 +96,7 @@ androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", versi androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } -androidx-lifecycle-runtime = "androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha04" +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } @@ -112,6 +115,8 @@ androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "a androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } androidx-test-runner = "androidx.test:runner:1.5.2" androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" } +androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } +androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" } diff --git a/Jetcaster/settings.gradle.kts b/Jetcaster/settings.gradle.kts index 966fed261f..00d250a2ba 100644 --- a/Jetcaster/settings.gradle.kts +++ b/Jetcaster/settings.gradle.kts @@ -36,3 +36,4 @@ dependencyResolutionManagement { } rootProject.name = "Jetcaster" include(":app", ":core", ":designsystem") +include(":tv-app") \ No newline at end of file diff --git a/Jetcaster/tv-app/.gitignore b/Jetcaster/tv-app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/tv-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/tv-app/build.gradle.kts b/Jetcaster/tv-app/build.gradle.kts new file mode 100644 index 0000000000..f865233186 --- /dev/null +++ b/Jetcaster/tv-app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) +} + +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 + } + + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + 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.ui.tooling.preview) + implementation(libs.androidx.tv.foundation) + implementation(libs.androidx.tv.material) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.activity.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) +} \ No newline at end of file diff --git a/Jetcaster/tv-app/proguard-rules.pro b/Jetcaster/tv-app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/tv-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/tv-app/src/main/AndroidManifest.xml b/Jetcaster/tv-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0401edbf51 --- /dev/null +++ b/Jetcaster/tv-app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt new file mode 100644 index 0000000000..5821cc69c6 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt @@ -0,0 +1,48 @@ +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.tv.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.tooling.preview.Preview +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Surface +import com.example.jetcaster.tv.ui.theme.JetcasterTheme + +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalTvMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + JetcasterTheme { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape + ) { + Greeting("Android and World") + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun Greeting(name: String, modifier: Modifier = Modifier) { + Text( + text = "Hello $name!", + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + JetcasterTheme { + Greeting("Android") + } +} \ No newline at end of file diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt new file mode 100644 index 0000000000..85c8c04d32 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt new file mode 100644 index 0000000000..941b5d389f --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt @@ -0,0 +1,34 @@ +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun JetcasterTheme( + isInDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (isInDarkTheme) { + darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 + ) + } else { + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + ) + } + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt new file mode 100644 index 0000000000..f87284012c --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt @@ -0,0 +1,36 @@ +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Typography + +// Set of Material typography styles to start with +@OptIn(ExperimentalTvMaterial3Api::class) +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/Jetcaster/tv-app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Jetcaster/tv-app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/Jetcaster/tv-app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Jetcaster/tv-app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!To6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/Jetcaster/tv-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Jetcaster/tv-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..28d4b77f9f036a47549d47db79c16788749dca10 GIT binary patch literal 2884 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 + JetCaster + \ No newline at end of file diff --git a/Jetcaster/tv-app/src/main/res/values/themes.xml b/Jetcaster/tv-app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..a19b8510df --- /dev/null +++ b/Jetcaster/tv-app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + + + + diff --git a/Jetcaster/wear/src/main/res/values/colors.xml b/Jetcaster/wear/src/main/res/values/colors.xml new file mode 100644 index 0000000000..fd3d732d7d --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/colors.xml @@ -0,0 +1,20 @@ + + + + + #121212 + diff --git a/Jetcaster/wear/src/main/res/values/dimens.xml b/Jetcaster/wear/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..9b16e76d95 --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/dimens.xml @@ -0,0 +1,17 @@ + + + + + 48dp + diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1af68dc831 --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -0,0 +1,54 @@ + + + + Jetcaster + + Connection error + Unable to fetch podcasts feeds.\nCheck your internet connection and try again. + Retry + + Your podcasts + Latest episodes + + Your library + Discover + + Updated a while ago + + Updated %d week ago + Updated %d weeks ago + + + Updated yesterday + Updated %d days ago + + Updated today + + %1$s • %2$d mins + + Search + Account + Add + Back + More + Play + Skip previous + Reply 10 seconds + Forward 30 seconds + Skip next + Unfollow + Follow + Following + Not following + diff --git a/Jetcaster/wear/src/main/res/values/themes.xml b/Jetcaster/wear/src/main/res/values/themes.xml new file mode 100644 index 0000000000..c4dfa8ab7b --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/themes.xml @@ -0,0 +1,27 @@ + + + + + + From 196da0cfec7d8398dbf28c80e11570e0734cb6fe Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Thu, 21 Mar 2024 12:42:41 -0700 Subject: [PATCH 031/143] [Jetcaster]: Handle empty library state. --- .../com/example/jetcaster/ui/home/Home.kt | 117 +++++++++++++----- .../jetcaster/ui/home/HomeViewModel.kt | 18 ++- 2 files changed, 100 insertions(+), 35 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index d0fadc5f81..9a7c34b5ba 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -14,11 +14,14 @@ * limitations under the License. */ +@file:OptIn(ExperimentalFoundationApi::class) + package com.example.jetcaster.ui.home import androidx.compose.foundation.ExperimentalFoundationApi 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.PaddingValues @@ -26,7 +29,6 @@ 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 @@ -40,8 +42,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle @@ -58,12 +62,20 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -75,20 +87,21 @@ import coil.compose.AsyncImage import com.example.jetcaster.R 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.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.home.library.libraryItems import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime -import kotlinx.collections.immutable.PersistentList @Composable fun Home( @@ -110,6 +123,7 @@ fun Home( onPodcastUnfollowed = viewModel::onPodcastUnfollowed, navigateToPlayer = navigateToPlayer, onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, + onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, modifier = Modifier.fillMaxSize() ) } @@ -174,7 +188,15 @@ fun Home( onCategorySelected: (Category) -> Unit, navigateToPlayer: (String) -> Unit, onTogglePodcastFollowed: (String) -> Unit, + onLibraryPodcastSelected: (Podcast?) -> Unit ) { + // Effect that changes the home category selection when there are no subscribed podcasts + LaunchedEffect(key1 = featuredPodcasts) { + if (featuredPodcasts.isEmpty()) { + onHomeCategorySelected(HomeCategory.Discover) + } + } + Column( modifier = modifier.windowInsetsPadding( WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) @@ -221,7 +243,8 @@ fun Home( onHomeCategorySelected = onHomeCategorySelected, onCategorySelected = onCategorySelected, navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed + onTogglePodcastFollowed = onTogglePodcastFollowed, + onLibraryPodcastSelected = onLibraryPodcastSelected ) } } @@ -243,11 +266,18 @@ private fun HomeContent( onCategorySelected: (Category) -> Unit, navigateToPlayer: (String) -> Unit, onTogglePodcastFollowed: (String) -> Unit, + onLibraryPodcastSelected: (Podcast?) -> Unit ) { + val pagerState = rememberPagerState { featuredPodcasts.size } + LaunchedEffect(pagerState.currentPage, featuredPodcasts) { + val podcast = featuredPodcasts.getOrNull(pagerState.currentPage) + onLibraryPodcastSelected(podcast?.podcast) + } LazyColumn(modifier = modifier.fillMaxSize()) { if (featuredPodcasts.isNotEmpty()) { item { FollowedPodcastItem( + pagerState = pagerState, items = featuredPodcasts, onPodcastUnfollowed = onPodcastUnfollowed, modifier = Modifier @@ -265,7 +295,7 @@ private fun HomeContent( // TODO show a progress indicator or similar } - if (homeCategories.isNotEmpty()) { + if (featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty()) { stickyHeader { HomeCategoryTabs( categories = homeCategories, @@ -298,6 +328,7 @@ private fun HomeContent( @Composable private fun FollowedPodcastItem( + pagerState: PagerState, items: PersistentList, onPodcastUnfollowed: (String) -> Unit, modifier: Modifier = Modifier, @@ -306,11 +337,10 @@ private fun FollowedPodcastItem( Spacer(Modifier.height(16.dp)) FollowedPodcasts( + pagerState = pagerState, items = items, onPodcastUnfollowed = onPodcastUnfollowed, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) + modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(16.dp)) @@ -367,34 +397,54 @@ fun HomeCategoryTabIndicator( ) } +private val FEATURED_PODCAST_IMAGE_WIDTH_DP = 160.dp +private val FEATURED_PODCAST_IMAGE_HEIGHT_DP = 180.dp + +@OptIn(ExperimentalFoundationApi::class) @Composable fun FollowedPodcasts( + pagerState: PagerState, items: PersistentList, modifier: Modifier = Modifier, onPodcastUnfollowed: (String) -> Unit, ) { - // TODO: Update this component to a carousel once better support is available - val lastIndex = items.size - 1 - LazyRow( - modifier = modifier, + val coroutineScope = rememberCoroutineScope() + + var horizontalPadding by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + HorizontalPager( + state = pagerState, + modifier = modifier.onSizeChanged {size -> + // TODO: this 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. + horizontalPadding = with(density) { + (size.width.toDp() - FEATURED_PODCAST_IMAGE_WIDTH_DP) / 2 + } + }, contentPadding = PaddingValues( - start = Keyline1, - top = 16.dp, - end = Keyline1, + horizontal = horizontalPadding, + vertical = 16.dp, + ), + pageSize = PageSize.Fixed(180.dp) + ) { page -> + val (podcast, lastEpisodeDate) = items[page] + FollowedPodcastCarouselItem( + podcastImageUrl = podcast.imageUrl, + podcastTitle = podcast.title, + onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, + lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, + modifier = Modifier + .fillMaxSize() + .clickable { + coroutineScope.launch { + pagerState.animateScrollToPage(page) + } + } ) - ) { - itemsIndexed(items) { index: Int, - (podcast, lastEpisodeDate): PodcastWithExtraInfo -> - FollowedPodcastCarouselItem( - podcastImageUrl = podcast.imageUrl, - podcastTitle = podcast.title, - onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, - lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, - modifier = Modifier.padding(4.dp) - ) - - if (index < lastIndex) Spacer(Modifier.width(24.dp)) - } } } @@ -409,9 +459,9 @@ private fun FollowedPodcastCarouselItem( Column(modifier) { Box( Modifier - .weight(1f) + .height(FEATURED_PODCAST_IMAGE_HEIGHT_DP) + .width(FEATURED_PODCAST_IMAGE_WIDTH_DP) .align(Alignment.CenterHorizontally) - .aspectRatio(1f) ) { if (podcastImageUrl != null) { AsyncImage( @@ -484,7 +534,8 @@ fun PreviewHomeContent() { onPodcastUnfollowed = {}, navigateToPlayer = {}, onHomeCategorySelected = {}, - onTogglePodcastFollowed = {} + onTogglePodcastFollowed = {}, + onLibraryPodcastSelected = {} ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 3848cde1c2..2410c73a3e 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase @@ -27,6 +28,7 @@ import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +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.util.combine @@ -44,6 +46,7 @@ import kotlinx.coroutines.launch class HomeViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, private val podcastStore: PodcastStore = Graph.podcastStore, + private val episodeStore: EpisodeStore = Graph.episodeStore, private val getLatestFollowedEpisodesUseCase: GetLatestFollowedEpisodesUseCase = Graph.getLatestFollowedEpisodesUseCase, private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase = @@ -51,6 +54,8 @@ class HomeViewModel( private val filterableCategoriesUseCase: FilterableCategoriesUseCase = Graph.filterableCategoriesUseCase ) : ViewModel() { + // Holds our currently selected podcast in the library + private val selectedLibraryPodcast = MutableStateFlow(null) // Holds our currently selected home category private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover) // Holds the currently available home categories @@ -72,7 +77,7 @@ class HomeViewModel( combine( homeCategories, selectedHomeCategory, - podcastStore.followedPodcastsSortedByLastEpisode(limit = 20), + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), refreshing, _selectedCategory.flatMapLatest { selectedCategory -> filterableCategoriesUseCase(selectedCategory) @@ -80,7 +85,12 @@ class HomeViewModel( _selectedCategory.flatMapLatest { podcastCategoryFilterUseCase(it) }, - getLatestFollowedEpisodesUseCase() + selectedLibraryPodcast.flatMapLatest { + episodeStore.episodesInPodcast( + podcastUri = it?.uri ?: "", + limit = 20 + ) + } ) { homeCategories, selectedHomeCategory, podcasts, @@ -143,6 +153,10 @@ class HomeViewModel( podcastStore.togglePodcastFollowed(podcastUri) } } + + fun onLibraryPodcastSelected(podcast: Podcast?) { + selectedLibraryPodcast.value = podcast + } } enum class HomeCategory { From fa6fd4aa7600757c3bd63803b52f78f272420eb3 Mon Sep 17 00:00:00 2001 From: arriolac Date: Fri, 22 Mar 2024 00:06:39 +0000 Subject: [PATCH 032/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 9a7c34b5ba..16c91e9a45 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -97,11 +97,11 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.launch @Composable fun Home( @@ -415,7 +415,7 @@ fun FollowedPodcasts( val screenWidth = LocalConfiguration.current.screenWidthDp.dp HorizontalPager( state = pagerState, - modifier = modifier.onSizeChanged {size -> + modifier = modifier.onSizeChanged { size -> // TODO: this 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` From fe65afe197183473ea213864b3c656ee28d2e773 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 25 Mar 2024 13:58:44 -0700 Subject: [PATCH 033/143] Update Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt Co-authored-by: Ben Trengrove --- .../src/main/java/com/example/jetcaster/ui/home/Home.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 16c91e9a45..90eb016a2f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -269,9 +269,12 @@ private fun HomeContent( onLibraryPodcastSelected: (Podcast?) -> Unit ) { val pagerState = rememberPagerState { featuredPodcasts.size } - LaunchedEffect(pagerState.currentPage, featuredPodcasts) { - val podcast = featuredPodcasts.getOrNull(pagerState.currentPage) - onLibraryPodcastSelected(podcast?.podcast) +LaunchedEffect(pagerState, featuredPodcasts) { + snapshotFlow { pagerState.currentPage } + .collect { + val podcast = featuredPodcasts.getOrNull(pagerState.currentPage) + onLibraryPodcastSelected(podcast?.podcast) + } } LazyColumn(modifier = modifier.fillMaxSize()) { if (featuredPodcasts.isNotEmpty()) { From 5897c0c9a9af9b6de32007a31e78bc3df70cb99b Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 25 Mar 2024 21:01:06 +0000 Subject: [PATCH 034/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 90eb016a2f..53fcf0a1e0 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -269,7 +269,7 @@ private fun HomeContent( onLibraryPodcastSelected: (Podcast?) -> Unit ) { val pagerState = rememberPagerState { featuredPodcasts.size } -LaunchedEffect(pagerState, featuredPodcasts) { + LaunchedEffect(pagerState, featuredPodcasts) { snapshotFlow { pagerState.currentPage } .collect { val podcast = featuredPodcasts.getOrNull(pagerState.currentPage) From 35f192c03ab1250475ddb9e2254ae22966a90101 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 25 Mar 2024 14:12:00 -0700 Subject: [PATCH 035/143] PR Feedback. --- .../com/example/jetcaster/ui/home/Home.kt | 77 ++++++++----------- .../jetcaster/ui/home/HomeViewModel.kt | 9 ++- 2 files changed, 41 insertions(+), 45 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 53fcf0a1e0..515923578c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -23,6 +23,7 @@ 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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -64,18 +65,13 @@ import androidx.compose.material3.TopAppBar 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.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -97,11 +93,11 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime -import kotlinx.collections.immutable.PersistentList -import kotlinx.coroutines.launch @Composable fun Home( @@ -412,42 +408,37 @@ fun FollowedPodcasts( onPodcastUnfollowed: (String) -> Unit, ) { val coroutineScope = rememberCoroutineScope() - - var horizontalPadding by remember { mutableStateOf(0.dp) } - val density = LocalDensity.current - val screenWidth = LocalConfiguration.current.screenWidthDp.dp - HorizontalPager( - state = pagerState, - modifier = modifier.onSizeChanged { size -> - // TODO: this 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. - horizontalPadding = with(density) { - (size.width.toDp() - FEATURED_PODCAST_IMAGE_WIDTH_DP) / 2 - } - }, - contentPadding = PaddingValues( - horizontal = horizontalPadding, - vertical = 16.dp, - ), - pageSize = PageSize.Fixed(180.dp) - ) { page -> - val (podcast, lastEpisodeDate) = items[page] - FollowedPodcastCarouselItem( - podcastImageUrl = podcast.imageUrl, - podcastTitle = podcast.title, - onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, - lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, - modifier = Modifier - .fillMaxSize() - .clickable { - coroutineScope.launch { - pagerState.animateScrollToPage(page) + // 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) { + val horizontalPadding = (this.maxWidth - FEATURED_PODCAST_IMAGE_WIDTH_DP) / 2 + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = 16.dp, + ), + pageSpacing = 24.dp, + pageSize = PageSize.Fixed(FEATURED_PODCAST_IMAGE_WIDTH_DP) + ) { page -> + val (podcast, lastEpisodeDate) = items[page] + FollowedPodcastCarouselItem( + podcastImageUrl = podcast.imageUrl, + podcastTitle = podcast.title, + onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, + lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, + modifier = Modifier + .fillMaxSize() + .clickable { + coroutineScope.launch { + pagerState.animateScrollToPage(page) + } } - } - ) + ) + } } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 2410c73a3e..b7d29b3242 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -92,7 +92,7 @@ class HomeViewModel( ) } ) { homeCategories, - selectedHomeCategory, + homeCategory, podcasts, refreshing, filterableCategories, @@ -101,9 +101,14 @@ class HomeViewModel( _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 + HomeViewState( homeCategories = homeCategories, - selectedHomeCategory = selectedHomeCategory, + selectedHomeCategory = homeCategory, featuredPodcasts = podcasts.toPersistentList(), refreshing = refreshing, filterableCategoriesModel = filterableCategories, From 25fa1ba0b64750c573558d723b7f5e4da089af31 Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 25 Mar 2024 21:56:54 +0000 Subject: [PATCH 036/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 515923578c..e00314afef 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -93,11 +93,11 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.launch @Composable fun Home( From d97138544a0c18bec4a2ea047f7179223bc2432f Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 26 Mar 2024 11:13:04 +0900 Subject: [PATCH 037/143] Add the library screen to TV app --- .../example/jetcaster/tv/ui/JetcasterApp.kt | 16 +- .../jetcaster/tv/ui/component/Catalog.kt | 284 ++++++++++++++++++ .../tv/ui/discover/DiscoverScreen.kt | 280 ++--------------- .../tv/ui/discover/DiscoverScreenViewModel.kt | 11 + .../jetcaster/tv/ui/library/LibraryScreen.kt | 59 +++- .../tv/ui/library/LibraryScreenViewModel.kt | 99 ++++++ .../tv-app/src/main/res/values/strings.xml | 3 + 7 files changed, 489 insertions(+), 263 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index ede2c51318..1c732e9861 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -19,6 +19,7 @@ package com.example.jetcaster.tv.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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 @@ -111,7 +112,7 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat Text(text = "Settings") } } - } + }, ) { Route(jetcasterAppState = jetcasterAppState) } @@ -157,19 +158,26 @@ private fun Route(jetcasterAppState: JetcasterAppState) { NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { composable(Screen.Discover.route) { DiscoverScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize() ) } composable(Screen.Library.route) { LibraryScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + navigateToDiscover = jetcasterAppState::navigateToDiscover, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize() ) } composable(Screen.Search.route) { SearchScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize() ) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt new file mode 100644 index 0000000000..f1bda1b9ed --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -0,0 +1,284 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.Card +import androidx.tv.material3.CardScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.StandardCardLayout +import androidx.tv.material3.Text +import androidx.tv.material3.WideCardLayout +import coil.compose.AsyncImage +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.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +internal fun Catalog( + podcastList: PodcastList, + latestEpisodeList: EpisodeList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + header: (@Composable () -> Unit)? = null, +) { + TvLazyColumn( + modifier = modifier, + contentPadding = JetcasterAppDefaults.overScanMargin + .copy(start = 0.dp, end = 0.dp) + .intoPaddingValues(), + verticalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + ) { + if (header != null) { + item { header() } + } + item { + PodcastSection( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + title = stringResource(R.string.label_podcast) + ) + } + item { + LatestEpisodeSection( + episodeList = latestEpisodeList, + onEpisodeSelected = {}, + title = stringResource(R.string.label_latest_episode) + ) + } + } +} + +@Composable +private fun PodcastSection( + podcastList: PodcastList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + title: String? = null, +) { + Section( + title = title, + modifier = modifier + ) { + PodcastRow(podcastList = podcastList, onPodcastSelected = onPodcastSelected) + } +} + +@Composable +private fun LatestEpisodeSection( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + title: String? = null +) { + Section( + modifier = modifier, + title = title + ) { + EpisodeRow(episodeList = episodeList, onEpisodeSelected = onEpisodeSelected) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@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() + } +} + +@Composable +private fun PodcastRow( + podcastList: PodcastList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), +) { + TvLazyRow( + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + modifier = modifier, + ) { + items(podcastList) { + PodcastCard( + podcast = it.podcast, + onClick = { onPodcastSelected(it) }, + modifier = Modifier.width(JetcasterAppDefaults.cardWidth.medium) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PodcastCard( + podcast: Podcast, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + StandardCardLayout( + imageCard = { + Card( + onClick = onClick, + interactionSource = it, + scale = CardScale.None, + ) { + AsyncImage(model = podcast.imageUrl, contentDescription = null) + } + }, + title = { + Text(text = podcast.title, modifier = Modifier.padding(top = 12.dp)) + }, + modifier = modifier, + ) +} + +@Composable +private fun EpisodeRow( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), +) { + TvLazyRow( + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + modifier = modifier, + ) { + items(episodeList) { + EpisodeCard( + episode = it, + onClick = { onEpisodeSelected(it) }, + modifier = Modifier.width(JetcasterAppDefaults.cardWidth.small) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeCard( + episode: EpisodeToPodcast, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + WideCardLayout( + imageCard = { + EpisodeThumbnail(episode = episode, onClick = onClick, modifier = modifier) + }, + title = { + EpisodeMetaData( + episode = episode, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .width(JetcasterAppDefaults.cardWidth.small * 2) + ) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeThumbnail( + episode: EpisodeToPodcast, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + Card( + onClick = onClick, + interactionSource = interactionSource, + scale = CardScale.None, + modifier = modifier, + ) { + AsyncImage(model = episode.podcast.imageUrl, contentDescription = null) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modifier) { + val publishedDate = episode.episode.published + val duration = episode.episode.duration + Column(modifier = modifier) { + Text( + text = episode.episode.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) + if (duration != null) { + Spacer( + modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) + ) + Text( + text = stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(publishedDate), + duration.toMinutes().toInt() + ), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 41154160c9..57b2cf481b 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -16,53 +16,30 @@ package com.example.jetcaster.tv.ui.discover -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.layout.width 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.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.tv.foundation.lazy.list.TvLazyColumn -import androidx.tv.foundation.lazy.list.TvLazyRow -import androidx.tv.foundation.lazy.list.items -import androidx.tv.material3.Card -import androidx.tv.material3.CardScale import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.StandardCardLayout import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text -import androidx.tv.material3.WideCardLayout -import coil.compose.AsyncImage 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.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo -import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.component.Catalog import com.example.jetcaster.tv.ui.component.Loading -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle @Composable fun DiscoverScreen( @@ -81,12 +58,12 @@ fun DiscoverScreen( } is DiscoverScreenUiState.Ready -> { - Catalog( + CatalogWithCategorySelection( categoryList = s.categoryList, podcastList = s.podcastList, selectedCategory = s.selectedCategory, latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = {}, + onPodcastSelected = discoverScreenViewModel::subscribe, onCategorySelected = discoverScreenViewModel::selectCategory, modifier = Modifier .fillMaxSize() @@ -96,9 +73,9 @@ fun DiscoverScreen( } } -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -private fun Catalog( +private fun CatalogWithCategorySelection( categoryList: CategoryList, podcastList: PodcastList, selectedCategory: Category, @@ -113,238 +90,29 @@ private fun Catalog( tabRow.requestFocus() } - TvLazyColumn( + Catalog( + podcastList = podcastList, + latestEpisodeList = latestEpisodeList, + onPodcastSelected = onPodcastSelected, modifier = modifier, - contentPadding = JetcasterAppDefaults - .overScanMargin - .copy(start = 0.dp, end = 0.dp) - .intoPaddingValues(), - verticalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) ) { - item { - TabRow( - selectedTabIndex = categoryList.indexOf(selectedCategory), - modifier = Modifier.focusRequester(tabRow) - ) { - categoryList.forEach { - Tab(selected = it == selectedCategory, onFocus = { onCategorySelected(it) }) { - Text( - text = it.name, - modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) - ) + TabRow( + selectedTabIndex = categoryList.indexOf(selectedCategory), + modifier = Modifier.focusRequester(tabRow) + ) { + categoryList.forEach { + Tab( + selected = it == selectedCategory, + onFocus = { + onCategorySelected(it) } + ) { + Text( + text = it.name, + modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) + ) } } } - item { - PodcastSection( - podcastList = podcastList, - onPodcastSelected = onPodcastSelected, - title = stringResource(R.string.label_podcast) - ) - } - item { - LatestEpisodeSection( - episodeList = latestEpisodeList, - onEpisodeSelected = {}, - title = stringResource(R.string.label_latest_episode) - ) - } - } -} - -@Composable -private fun PodcastSection( - podcastList: PodcastList, - onPodcastSelected: (PodcastWithExtraInfo) -> Unit, - modifier: Modifier = Modifier, - title: String? = null, -) { - Section( - title = title, - modifier = modifier - ) { - PodcastRow(podcastList = podcastList, onPodcastSelected = onPodcastSelected) - } -} - -@Composable -private fun LatestEpisodeSection( - episodeList: EpisodeList, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, - modifier: Modifier = Modifier, - title: String? = null -) { - Section( - modifier = modifier, - title = title - ) { - EpisodeRow(episodeList = episodeList, onEpisodeSelected = onEpisodeSelected) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@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() - } -} - -@Composable -private fun PodcastRow( - podcastList: PodcastList, - onPodcastSelected: (PodcastWithExtraInfo) -> Unit, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(), - horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), -) { - TvLazyRow( - contentPadding = contentPadding, - horizontalArrangement = horizontalArrangement, - modifier = modifier, - ) { - items(podcastList) { - PodcastCard( - podcast = it.podcast, - onClick = { onPodcastSelected(it) }, - modifier = Modifier.width(JetcasterAppDefaults.cardWidth.medium) - ) - } } } - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun PodcastCard( - podcast: Podcast, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - StandardCardLayout( - imageCard = { - Card( - onClick = onClick, - interactionSource = it, - scale = CardScale.None, - ) { - AsyncImage(model = podcast.imageUrl, contentDescription = null) - } - }, - title = { - Text(text = podcast.title, modifier = Modifier.padding(top = 12.dp)) - }, - modifier = modifier, - ) -} - -@Composable -private fun EpisodeRow( - episodeList: EpisodeList, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(), - horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), -) { - TvLazyRow( - contentPadding = contentPadding, - horizontalArrangement = horizontalArrangement, - modifier = modifier, - ) { - items(episodeList) { - EpisodeCard( - episode = it, - onClick = { onEpisodeSelected(it) }, - modifier = Modifier.width(JetcasterAppDefaults.cardWidth.small) - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeCard( - episode: EpisodeToPodcast, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - WideCardLayout( - imageCard = { - EpisodeThumbnail(episode = episode, onClick = onClick, modifier = modifier) - }, - title = { - EpisodeMetaData( - episode = episode, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) - .width(JetcasterAppDefaults.cardWidth.small * 2) - ) - }, - ) -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeThumbnail( - episode: EpisodeToPodcast, - onClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } -) { - Card( - onClick = onClick, - interactionSource = interactionSource, - scale = CardScale.None, - modifier = modifier, - ) { - AsyncImage(model = episode.podcast.imageUrl, contentDescription = null) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modifier) { - val publishedDate = episode.episode.published - val duration = episode.episode.duration - Column(modifier = modifier) { - Text( - text = episode.episode.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) - if (duration != null) { - Spacer( - modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) - ) - Text( - text = stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(publishedDate), - duration.toMinutes().toInt() - ), - style = MaterialTheme.typography.bodySmall - ) - } - } -} - -private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) -} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 22e1902a07..911b92eb03 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -20,8 +20,10 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.di.Graph 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.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList @@ -39,6 +41,7 @@ import kotlinx.coroutines.launch class DiscoverScreenViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, private val categoryStore: CategoryStore = Graph.categoryStore, + private val podcastStore: PodcastStore = Graph.podcastStore, ) : ViewModel() { private val _selectedCategory = MutableStateFlow(null) @@ -102,6 +105,14 @@ class DiscoverScreenViewModel( _selectedCategory.value = category } + fun subscribe(podcastWithExtraInfo: PodcastWithExtraInfo) { + if (!podcastWithExtraInfo.isFollowed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcastWithExtraInfo.podcast.uri) + } + } + } + private fun refresh() { viewModelScope.launch { podcastsRepository.updatePodcasts(false) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index fbcc617af2..bd2d70ac09 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -16,13 +16,66 @@ 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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.example.jetcaster.tv.ui.component.NotAvailableFeature +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.component.Catalog +import com.example.jetcaster.tv.ui.component.Loading @Composable fun LibraryScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + navigateToDiscover: () -> Unit, + libraryScreenViewModel: LibraryScreenViewModel = viewModel() ) { - NotAvailableFeature(modifier = modifier) + 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 -> Catalog( + podcastList = s.subscribedPodcastList, + latestEpisodeList = s.latestEpisodeList, + onPodcastSelected = libraryScreenViewModel::unsubscribe, + modifier = modifier + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun NavigateToDiscover( + onNavigationRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + 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.gapSettings.catalogItemGap) + ) { + Text(text = stringResource(id = R.string.label_navigate_to_discover)) + } + } + } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt new file mode 100644 index 0000000000..bc0fef41a5 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -0,0 +1,99 @@ +/* + * 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.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.di.Graph +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.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +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 + +class LibraryScreenViewModel( + private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, + private val episodeStore: EpisodeStore = Graph.episodeStore, + private val podcastStore: PodcastStore = Graph.podcastStore, +) : ViewModel() { + + private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode().map { + PodcastList(it) + } + + @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 { + EpisodeList(it) + } + + 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 + ) + + fun unsubscribe(podcast: PodcastWithExtraInfo) { + if (podcast.isFollowed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.podcast.uri) + } + } + } + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +sealed interface LibraryScreenUiState { + data object Loading : LibraryScreenUiState + data object NoSubscribedPodcast : LibraryScreenUiState + data class Ready( + val subscribedPodcastList: PodcastList, + val latestEpisodeList: EpisodeList, + ) : LibraryScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 2c853aa4a6..ea00a4daf9 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ JetCaster This feature is not available yet. Loading + Let\'s discover the podcasts! + You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them! Podcast Latest Episodes Subscribe @@ -32,6 +34,7 @@ Podcasts Episodes Latest Episodes + Discover the podcasts Updated a while ago From 861774f4e0cb3189eeda3b3d2046ccefd2ac2db7 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 26 Mar 2024 15:51:12 +0900 Subject: [PATCH 038/143] Add podcast details screen to the TV app --- .../example/jetcaster/tv/ui/JetcasterApp.kt | 23 +- .../jetcaster/tv/ui/JetcasterAppState.kt | 28 +- .../tv/ui/component/ButtonWithIcon.kt | 48 ++++ .../jetcaster/tv/ui/component/Catalog.kt | 15 +- .../tv/ui/component/EpisodeDateAndDuration.kt | 53 ++++ .../jetcaster/tv/ui/component/ErrorState.kt | 63 +++++ .../tv/ui/discover/DiscoverScreen.kt | 4 +- .../jetcaster/tv/ui/library/LibraryScreen.kt | 16 +- .../tv/ui/library/LibraryScreenViewModel.kt | 9 - .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 266 ++++++++++++++++++ .../tv/ui/podcast/PodcastScreenViewModel.kt | 121 ++++++++ .../tv-app/src/main/res/values/strings.xml | 3 + 12 files changed, 608 insertions(+), 41 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index 1c732e9861..ff74c1bf7e 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -46,6 +47,8 @@ import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text import com.example.jetcaster.tv.ui.discover.DiscoverScreen import com.example.jetcaster.tv.ui.library.LibraryScreen +import com.example.jetcaster.tv.ui.podcast.PodcastScreen +import com.example.jetcaster.tv.ui.podcast.PodcastScreenViewModel import com.example.jetcaster.tv.ui.profile.ProfileScreen import com.example.jetcaster.tv.ui.search.SearchScreen import com.example.jetcaster.tv.ui.settings.SettingsScreen @@ -158,6 +161,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { composable(Screen.Discover.route) { DiscoverScreen( + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) .fillMaxSize() @@ -167,6 +173,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { composable(Screen.Library.route) { LibraryScreen( navigateToDiscover = jetcasterAppState::navigateToDiscover, + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.podcast.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) .fillMaxSize() @@ -181,8 +190,18 @@ private fun Route(jetcasterAppState: JetcasterAppState) { ) } - composable(Screen.Show.route) { - Text(text = "Show") + composable(Screen.Podcast.route) { + val podcastScreenViewModel: PodcastScreenViewModel = viewModel( + factory = PodcastScreenViewModel.factory + ) + PodcastScreen( + podcastScreenViewModel = podcastScreenViewModel, + backToHomeScreen = jetcasterAppState::navigateToDiscover, + playEpisode = {}, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize(), + ) } composable(Screen.Player.route) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt index 5cb595729c..600f2c6d16 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -16,6 +16,7 @@ package com.example.jetcaster.tv.ui +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.navigation.NavHostController @@ -45,7 +46,8 @@ class JetcasterAppState( } fun showPodcastDetails(podcastUri: String) { - val screen = Screen.Show(podcastUri) + val encodedUrL = Uri.encode(podcastUri) + val screen = Screen.Podcast(encodedUrL) navHostController.navigate(screen.route) } @@ -71,40 +73,40 @@ sealed interface Screen { val route: String data object Discover : Screen { - override val route = "/" + override val route = "/discover" } data object Library : Screen { - override val route = "/library" + override val route = "library" } data object Search : Screen { - override val route = "/search" + override val route = "search" } data object Profile : Screen { - override val route = "/profile" + override val route = "profile" } data object Settings : Screen { - override val route: String = "/settings" + override val route: String = "settings" } - data class Show(private val podcastUri: String) : Screen { - override val route = "$root/$podcastUri" + data class Podcast(private val podcastUri: String) : Screen { + override val route = "$ROOT/$podcastUri" companion object : Screen { - private const val root = "/show" - override val route = "$root/{showUri}" + private const val ROOT = "podcast" + override val route = "$ROOT/{podcastUri}" } } data class Player(private val episodeUri: String) : Screen { - override val route = "$root/$episodeUri" + override val route = "$ROOT/$episodeUri" companion object : Screen { - private const val root = "/player" - override val route = "$root/{episodeUri}" + private const val ROOT = "player" + override val route = "$ROOT/{episodeUri}" } } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt new file mode 100644 index 0000000000..f39f87e12f --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt @@ -0,0 +1,48 @@ +/* + * 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.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.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.Text + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun ButtonWithIcon( + label: String, + icon: ImageVector, + onClick: () -> Unit, + scale: ButtonScale = ButtonDefaults.scale(), + modifier: Modifier = Modifier, +) { + Button(onClick = onClick, modifier = modifier, scale = scale) { + Icon( + icon, + contentDescription = null, + Modifier.padding(top = 10.dp, bottom = 10.dp, start = 12.dp, end = 6.dp) + ) + Text(text = label, modifier = Modifier.padding(top = 10.dp, bottom = 10.dp, end = 16.dp)) + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index f1bda1b9ed..9a31b2a456 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -49,8 +49,6 @@ 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.JetcasterAppDefaults -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle @Composable internal fun Catalog( @@ -267,18 +265,7 @@ private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modi Spacer( modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) ) - Text( - text = stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(publishedDate), - duration.toMinutes().toInt() - ), - style = MaterialTheme.typography.bodySmall - ) + EpisodeDataAndDuration(offsetDateTime = publishedDate, duration = duration) } } } - -private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) -} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt new file mode 100644 index 0000000000..d886364521 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt @@ -0,0 +1,53 @@ +/* + * 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.ExperimentalTvMaterial3Api +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) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@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 + ) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt new file mode 100644 index 0000000000..e60359af6f --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.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.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.ui.JetcasterAppDefaults + +@OptIn(ExperimentalTvMaterial3Api::class) +@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.gapSettings.catalogItemGap) + .focusRequester(focusRequester) + ) { + Text(text = stringResource(R.string.label_back_to_home)) + } + } + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 57b2cf481b..8f7dcd4c34 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -33,6 +33,7 @@ import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text 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.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList @@ -43,6 +44,7 @@ import com.example.jetcaster.tv.ui.component.Loading @Composable fun DiscoverScreen( + showPodcastDetails: (Podcast) -> Unit, modifier: Modifier = Modifier, discoverScreenViewModel: DiscoverScreenViewModel = viewModel() ) { @@ -63,7 +65,7 @@ fun DiscoverScreen( podcastList = s.podcastList, selectedCategory = s.selectedCategory, latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = discoverScreenViewModel::subscribe, + onPodcastSelected = { showPodcastDetails(it.podcast) }, onCategorySelected = discoverScreenViewModel::selectCategory, modifier = Modifier .fillMaxSize() diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index bd2d70ac09..e444620fb7 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -20,16 +20,21 @@ 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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.R import com.example.jetcaster.tv.ui.JetcasterAppDefaults import com.example.jetcaster.tv.ui.component.Catalog @@ -39,6 +44,7 @@ import com.example.jetcaster.tv.ui.component.Loading fun LibraryScreen( modifier: Modifier = Modifier, navigateToDiscover: () -> Unit, + showPodcastDetails: (PodcastWithExtraInfo) -> Unit, libraryScreenViewModel: LibraryScreenViewModel = viewModel() ) { val uiState by libraryScreenViewModel.uiState.collectAsState() @@ -51,7 +57,7 @@ fun LibraryScreen( is LibraryScreenUiState.Ready -> Catalog( podcastList = s.subscribedPodcastList, latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = libraryScreenViewModel::unsubscribe, + onPodcastSelected = showPodcastDetails, modifier = modifier ) } @@ -63,6 +69,10 @@ private fun NavigateToDiscover( onNavigationRequested: () -> Unit, modifier: Modifier = Modifier, ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } Box(modifier = modifier, contentAlignment = Alignment.Center) { Column { Text( @@ -72,7 +82,9 @@ private fun NavigateToDiscover( Text(text = stringResource(id = R.string.message_no_subscribed_podcast)) Button( onClick = onNavigationRequested, - modifier = Modifier.padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + modifier = Modifier + .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .focusRequester(focusRequester) ) { Text(text = stringResource(id = R.string.label_navigate_to_discover)) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index bc0fef41a5..07f624122a 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -18,7 +18,6 @@ package com.example.jetcaster.tv.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore @@ -74,14 +73,6 @@ class LibraryScreenViewModel( LibraryScreenUiState.Loading ) - fun unsubscribe(podcast: PodcastWithExtraInfo) { - if (podcast.isFollowed) { - viewModelScope.launch { - podcastStore.togglePodcastFollowed(podcast.podcast.uri) - } - } - } - init { viewModelScope.launch { podcastsRepository.updatePodcasts(false) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt new file mode 100644 index 0000000000..53d92a7512 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -0,0 +1,266 @@ +/* + * 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.focusGroup +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.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil.compose.AsyncImage +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.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.component.ButtonWithIcon +import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration +import com.example.jetcaster.tv.ui.component.ErrorState +import com.example.jetcaster.tv.ui.component.Loading + +@Composable +fun PodcastScreen( + podcastScreenViewModel: PodcastScreenViewModel, + backToHomeScreen: () -> Unit, + playEpisode: (Episode) -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by podcastScreenViewModel.uiStateFlow.collectAsState() + when (val s = uiState) { + PodcastScreenUiState.Loading -> Loading(modifier = modifier) + PodcastScreenUiState.Error -> ErrorState(backToHome = backToHomeScreen, modifier = modifier) + is PodcastScreenUiState.Ready -> PodcastDetails( + podcast = s.podcast, + episodeList = s.episodeList, + isSubscribed = s.isSubscribed, + subscribe = podcastScreenViewModel::subscribe, + unsubscribe = podcastScreenViewModel::unsubscribe, + playEpisode = playEpisode, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun PodcastDetails( + podcast: Podcast, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + playEpisode: (Episode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + Row( + modifier = modifier.focusGroup(), + horizontalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + ) { + PodcastInfo( + podcast = podcast, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + modifier = Modifier.weight(1f), + ) + PodcastEpisodeList( + episodeList = episodeList, + onEpisodeSelected = { playEpisode(it.episode) }, + modifier = Modifier + .focusRequester(focusRequester) + .focusRestorer() + .weight(1f) + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PodcastInfo( + podcast: Podcast, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val author = podcast.author + val description = podcast.description + + Column(modifier = modifier) { + AsyncImage( + model = podcast.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(JetcasterAppDefaults.cardWidth.medium) + .aspectRatio(1f) + .clip( + RoundedCornerShape(12.dp) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + if (author != null) { + Text( + text = author, + style = MaterialTheme.typography.bodySmall + ) + } + Text( + text = podcast.title, + style = MaterialTheme.typography.headlineSmall, + ) + if (description != null) { + Text( + text = description, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium + ) + } + ToggleSubscriptionButton( + podcast, + isSubscribed, + subscribe, + unsubscribe, + modifier = Modifier + .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ToggleSubscriptionButton( + podcast: Podcast, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, 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(podcast, isSubscribed) }, + scale = ButtonDefaults.scale(scale = 1f), + modifier = modifier + ) +} + +@Composable +private fun PodcastEpisodeList( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier +) { + TvLazyColumn( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + modifier = modifier + ) { + items(episodeList) { + EpisodeListItem(episodeToPodcast = it, onEpisodeSelected = onEpisodeSelected) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeListItem( + episodeToPodcast: EpisodeToPodcast, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + ListItem( + selected = selected, + onClick = { onEpisodeSelected(episodeToPodcast) }, + modifier = modifier + ) { + Row( + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp) + ) { + EpisodeMetaData(episode = episodeToPodcast.episode) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeMetaData(episode: Episode, modifier: Modifier = Modifier) { + val published = episode.published + val duration = episode.duration + Column(modifier = modifier) { + Text( + text = episode.title, + style = MaterialTheme.typography.bodyMedium + ) + if (duration != null) { + EpisodeDataAndDuration(published, duration) + } + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt new file mode 100644 index 0000000000..5ea8d84690 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -0,0 +1,121 @@ +/* + * 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.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.tv.model.EpisodeList +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 + +class PodcastScreenViewModel( + handle: SavedStateHandle, + private val podcastStore: PodcastStore = Graph.podcastStore, + episodeStore: EpisodeStore = Graph.episodeStore, +) : ViewModel() { + + private val podcastUri = handle.get("podcastUri") ?: "uri://no/podcast/is/specified" + + private val podcastFlow = podcastStore.podcastWithUri(podcastUri).stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private val episodeListFlow = podcastFlow.flatMapLatest { + if (it != null) { + episodeStore.episodesInPodcast(it.uri) + } else { + flowOf(emptyList()) + } + }.map { + EpisodeList(it) + } + + 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, episodeList, isSubscribed) + } else { + PodcastScreenUiState.Error + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + PodcastScreenUiState.Loading + ) + + fun subscribe(podcast: Podcast, isSubscribed: Boolean) { + if (!isSubscribed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + } + + fun unsubscribe(podcast: Podcast, isSubscribed: Boolean) { + if (isSubscribed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + } + + companion object { + @Suppress("UNCHECKED_CAST") + val factory = object : AbstractSavedStateViewModelFactory() { + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return PodcastScreenViewModel( + handle + ) as T + } + } + } +} + +sealed interface PodcastScreenUiState { + data object Loading : PodcastScreenUiState + data object Error : PodcastScreenUiState + data class Ready( + val podcast: Podcast, + val episodeList: EpisodeList, + val isSubscribed: Boolean + ) : PodcastScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index ea00a4daf9..0378e4d1ed 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -20,9 +20,11 @@ Loading Let\'s discover the podcasts! You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them! + Something wrong happened Podcast Latest Episodes Subscribe + Unsubscribe Info Play Pause @@ -35,6 +37,7 @@ Episodes Latest Episodes Discover the podcasts + Back to Home Updated a while ago From 297db8c61f7769395cb9a7efa0d7903bf984136f Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 26 Mar 2024 16:36:06 +0900 Subject: [PATCH 039/143] Add background to the podcast details screen --- .../example/jetcaster/tv/ui/JetcasterApp.kt | 154 +++++++++--------- .../tv/ui/component/ButtonWithIcon.kt | 2 +- .../jetcaster/tv/ui/component/Catalog.kt | 4 +- .../tv/ui/library/LibraryScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 63 ++++++- 5 files changed, 144 insertions(+), 81 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index ff74c1bf7e..e97199174a 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -28,12 +28,8 @@ 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.remember 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.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -56,21 +52,62 @@ import com.example.jetcaster.tv.ui.settings.SettingsScreen @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) { - val discover = remember { FocusRequester() } + Route(jetcasterAppState = jetcasterAppState) +} + +internal data object JetcasterAppDefaults { + val overScanMargin = OverScanMarginSettings() + val gapSettings = GapSettings() + val cardWidth = CardWidth() + val padding = PaddingSettings() +} + +data class OverScanMarginSettings( + val default: OverScanMargin = OverScanMargin(), + val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), + val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), + val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp) +) + +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) + } +} + +data class CardWidth( + val large: Dp = 268.dp, + val medium: Dp = 196.dp, + val small: Dp = 124.dp +) +data class PaddingSettings( + val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), + val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) +) + +data class GapSettings( + val catalogItemGap: Dp = 20.dp, + val catalogSectionGap: Dp = 40.dp, +) + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun WithGlobalNavigation( + jetcasterAppState: JetcasterAppState, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { NavigationDrawer( drawerContent = { Column( modifier = Modifier - .padding( - JetcasterAppDefaults.overScanMargin - .copy( - start = 0.dp, - end = 0.dp - ) - .intoPaddingValues() - ) - .focusRestorer { discover } + .padding(JetcasterAppDefaults.overScanMargin.drawer.intoPaddingValues()) ) { NavigationDrawerItem( @@ -95,7 +132,6 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat selected = false, onClick = jetcasterAppState::navigateToDiscover, leadingContent = { Icon(Icons.Default.Home, contentDescription = null) }, - modifier = Modifier.focusRequester(discover) ) { Text(text = "Discover") } @@ -116,76 +152,46 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat } } }, - ) { - Route(jetcasterAppState = jetcasterAppState) - } + content = content, + modifier = modifier + ) } -internal data object JetcasterAppDefaults { - val overScanMargin = OverScanMargin() - val gapSettings = GapSettings() - val cardWidth = CardWidth() - val padding = PaddingSettings() -} - -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) - } -} - -data class CardWidth( - val large: Dp = 268.dp, - val medium: Dp = 196.dp, - val small: Dp = 124.dp -) - -data class PaddingSettings( - val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), - val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) -) - -data class GapSettings( - val catalogItemGap: Dp = 20.dp, - val catalogSectionGap: Dp = 40.dp, -) - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun Route(jetcasterAppState: JetcasterAppState) { NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { composable(Screen.Discover.route) { - DiscoverScreen( - showPodcastDetails = { - jetcasterAppState.showPodcastDetails(it.uri) - }, - modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) - .fillMaxSize() - ) + WithGlobalNavigation(jetcasterAppState = jetcasterAppState) { + DiscoverScreen( + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.uri) + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize() + ) + } } composable(Screen.Library.route) { - LibraryScreen( - navigateToDiscover = jetcasterAppState::navigateToDiscover, - showPodcastDetails = { - jetcasterAppState.showPodcastDetails(it.podcast.uri) - }, - modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) - .fillMaxSize() - ) + WithGlobalNavigation(jetcasterAppState = jetcasterAppState) { + LibraryScreen( + navigateToDiscover = jetcasterAppState::navigateToDiscover, + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.podcast.uri) + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize() + ) + } } composable(Screen.Search.route) { SearchScreen( modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) .fillMaxSize() ) } @@ -199,7 +205,7 @@ private fun Route(jetcasterAppState: JetcasterAppState) { backToHomeScreen = jetcasterAppState::navigateToDiscover, playEpisode = {}, modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .padding(JetcasterAppDefaults.overScanMargin.podcastDetails.intoPaddingValues()) .fillMaxSize(), ) } @@ -210,13 +216,15 @@ private fun Route(jetcasterAppState: JetcasterAppState) { composable(Screen.Profile.route) { ProfileScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) ) } composable(Screen.Settings.route) { SettingsScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) ) } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt index f39f87e12f..b6ad6723da 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt @@ -34,8 +34,8 @@ internal fun ButtonWithIcon( label: String, icon: ImageVector, onClick: () -> Unit, - scale: ButtonScale = ButtonDefaults.scale(), modifier: Modifier = Modifier, + scale: ButtonScale = ButtonDefaults.scale(), ) { Button(onClick = onClick, modifier = modifier, scale = scale) { Icon( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index 9a31b2a456..045c76983f 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -60,9 +60,7 @@ internal fun Catalog( ) { TvLazyColumn( modifier = modifier, - contentPadding = JetcasterAppDefaults.overScanMargin - .copy(start = 0.dp, end = 0.dp) - .intoPaddingValues(), + contentPadding = JetcasterAppDefaults.overScanMargin.catalog.intoPaddingValues(), verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) ) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index 07f624122a..1855cbb192 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -36,7 +36,7 @@ import kotlinx.coroutines.launch class LibraryScreenViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, private val episodeStore: EpisodeStore = Graph.episodeStore, - private val podcastStore: PodcastStore = Graph.podcastStore, + podcastStore: PodcastStore = Graph.podcastStore, ) : ViewModel() { private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode().map { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index 53d92a7512..252cabab5e 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -16,12 +16,13 @@ package com.example.jetcaster.tv.ui.podcast -import androidx.compose.foundation.focusGroup 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.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -37,9 +38,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer +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.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -74,7 +80,7 @@ fun PodcastScreen( when (val s = uiState) { PodcastScreenUiState.Loading -> Loading(modifier = modifier) PodcastScreenUiState.Error -> ErrorState(backToHome = backToHomeScreen, modifier = modifier) - is PodcastScreenUiState.Ready -> PodcastDetails( + is PodcastScreenUiState.Ready -> PodcastDetailsWithBackground( podcast = s.podcast, episodeList = s.episodeList, isSubscribed = s.isSubscribed, @@ -86,6 +92,32 @@ fun PodcastScreen( } } +@Composable +private fun PodcastDetailsWithBackground( + podcast: Podcast, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + playEpisode: (Episode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + Box { + Background(podcast = podcast) + PodcastDetails( + podcast = podcast, + episodeList = episodeList, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + playEpisode = playEpisode, + focusRequester = focusRequester, + modifier = modifier + ) + } +} + @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable private fun PodcastDetails( @@ -99,7 +131,7 @@ private fun PodcastDetails( focusRequester: FocusRequester = remember { FocusRequester() } ) { Row( - modifier = modifier.focusGroup(), + modifier = modifier, horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) ) { @@ -125,6 +157,31 @@ private fun PodcastDetails( } } +@Composable +private fun Background( + podcast: Podcast, + modifier: Modifier = Modifier, +) { + AsyncImage( + model = podcast.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .fillMaxWidth() + .drawWithCache { + val overlay = Brush.radialGradient( + listOf(Color.Black, Color.Transparent), + center = Offset(0f, size.height), + radius = size.width * 1.5f + ) + onDrawWithContent { + drawContent() + drawRect(overlay, blendMode = BlendMode.Multiply) + } + } + ) +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun PodcastInfo( From aeaa1d5a4398008ad5a775ec53bc185cbfa9f3d4 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 22 Mar 2024 15:45:49 -0700 Subject: [PATCH 040/143] [Jetcaster] Add support for playing an episode (mock) --- .../com/example/jetcaster/ui/JetcasterApp.kt | 2 +- .../com/example/jetcaster/ui/home/Home.kt | 13 +- .../jetcaster/ui/player/PlayerScreen.kt | 238 ++++++++++++++---- .../jetcaster/ui/player/PlayerViewModel.kt | 66 +++-- .../com/example/jetcaster/ui/theme/Theme.kt | 3 +- Jetcaster/app/src/main/res/values/strings.xml | 17 +- .../core/data/database/dao/EpisodesDao.kt | 9 + .../example/jetcaster/core/data/di/Graph.kt | 7 +- .../core/data/repository/EpisodeStore.kt | 8 + .../jetcaster/core/player/EpisodePlayer.kt | 64 +++++ .../core/player/MockEpisodePlayer.kt | 125 +++++++++ .../core/data/repository/TestEpisodeStore.kt | 15 +- 12 files changed, 482 insertions(+), 85 deletions(-) create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 838e9eb71b..93a8d90115 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -57,9 +57,9 @@ fun JetcasterApp( ) ) PlayerScreen( - playerViewModel, windowSizeClass, displayFeatures, + playerViewModel, onBackPress = appState::navigateBack ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index e00314afef..6be350dd65 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -37,11 +37,9 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize @@ -201,9 +199,6 @@ fun Home( // We dynamically theme this sub-section of the layout to match the selected // 'top podcast' - val surfaceColor = MaterialTheme.colorScheme.surface - val appBarColor = surfaceColor.copy(alpha = 0.87f) - val scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) // Top Bar @@ -213,14 +208,8 @@ fun Home( .background(color = scrimColor) ) { // Draw a scrim over the status bar which matches the app bar - Spacer( - Modifier - .background(appBarColor) - .fillMaxWidth() - .windowInsetsTopHeight(WindowInsets.statusBars) - ) HomeAppBar( - backgroundColor = appBarColor, + backgroundColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth() ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 27d73dd8bd..2a2027c645 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -19,6 +19,7 @@ package com.example.jetcaster.ui.player import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -44,16 +45,16 @@ 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.Forward30 +import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.MoreVert 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.rounded.PauseCircleFilled import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Surface @@ -62,6 +63,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -99,13 +101,23 @@ import java.time.Duration */ @Composable fun PlayerScreen( - viewModel: PlayerViewModel, windowSizeClass: WindowSizeClass, displayFeatures: List, + viewModel: PlayerViewModel, onBackPress: () -> Unit ) { val uiState = viewModel.uiState - PlayerScreen(uiState, windowSizeClass, displayFeatures, onBackPress) + PlayerScreen( + uiState, + windowSizeClass, + displayFeatures, + onBackPress, + onPlayPress = viewModel::onPlay, + onPausePress = viewModel::onPause, + onAdvanceBy = viewModel::onAdvanceBy, + onRewindBy = viewModel::onRewindBy, + onStop = viewModel::onStop + ) } /** @@ -117,11 +129,30 @@ private fun PlayerScreen( windowSizeClass: WindowSizeClass, displayFeatures: List, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onStop: () -> Unit, modifier: Modifier = Modifier ) { + DisposableEffect(Unit) { + onDispose { + onStop() + } + } Surface(modifier) { if (uiState.podcastName.isNotEmpty()) { - PlayerContent(uiState, windowSizeClass, displayFeatures, onBackPress) + PlayerContent( + uiState, + windowSizeClass, + displayFeatures, + onBackPress, + onPlayPress, + onPausePress, + onAdvanceBy, + onRewindBy + ) } else { FullScreenLoading() } @@ -134,6 +165,10 @@ fun PlayerContent( windowSizeClass: WindowSizeClass, displayFeatures: List, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, modifier: Modifier = Modifier ) { val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() @@ -162,7 +197,14 @@ fun PlayerContent( PlayerContentTableTopTop(uiState = uiState) }, second = { - PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) + PlayerContentTableTopBottom( + uiState = uiState, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy + ) }, strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), displayFeatures = displayFeatures, @@ -186,7 +228,13 @@ fun PlayerContent( PlayerContentBookStart(uiState = uiState) }, second = { - PlayerContentBookEnd(uiState = uiState) + PlayerContentBookEnd( + uiState = uiState, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy + ) }, strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), displayFeatures = displayFeatures @@ -194,7 +242,15 @@ fun PlayerContent( } } } else { - PlayerContentRegular(uiState, onBackPress, modifier) + PlayerContentRegular( + uiState, + onBackPress, + onPlayPress, + onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + modifier, + ) } } @@ -205,6 +261,10 @@ fun PlayerContent( private fun PlayerContentRegular( uiState: PlayerUiState, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -235,8 +295,18 @@ private fun PlayerContentRegular( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(10f) ) { - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) + PlayerSlider( + timeElapsed = uiState.timeElapsed, + episodeDuration = uiState.duration + ) + PlayerButtons( + isPlaying = uiState.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + Modifier.padding(vertical = 8.dp) + ) } Spacer(modifier = Modifier.weight(1f)) } @@ -279,6 +349,10 @@ private fun PlayerContentTableTopTop( private fun PlayerContentTableTopBottom( uiState: PlayerUiState, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, modifier: Modifier = Modifier ) { // Content for the table part of the screen @@ -303,8 +377,19 @@ private fun PlayerContentTableTopBottom( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(10f) ) { - PlayerButtons(playerButtonSize = 92.dp, modifier = Modifier.padding(top = 8.dp)) - PlayerSlider(uiState.duration) + PlayerButtons( + isPlaying = uiState.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + playerButtonSize = 92.dp, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + modifier = Modifier.padding(top = 8.dp) + ) + PlayerSlider( + timeElapsed = uiState.timeElapsed, + episodeDuration = uiState.duration + ) } } } @@ -344,6 +429,10 @@ private fun PlayerContentBookStart( @Composable private fun PlayerContentBookEnd( uiState: PlayerUiState, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -359,8 +448,18 @@ private fun PlayerContentBookEnd( .padding(vertical = 16.dp) .weight(1f) ) - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) + PlayerSlider( + timeElapsed = uiState.timeElapsed, + episodeDuration = uiState.duration + ) + PlayerButtons( + isPlaying = uiState.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + Modifier.padding(vertical = 8.dp) + ) } } @@ -462,25 +561,42 @@ private fun PodcastInformation( } } +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(episodeDuration: Duration?) { - if (episodeDuration != null) { - Column(Modifier.fillMaxWidth()) { - Slider(value = 0f, onValueChange = { }) - Row(Modifier.fillMaxWidth()) { - Text(text = "0s") - Spacer(modifier = Modifier.weight(1f)) - Text("${episodeDuration.seconds}s") - } +private fun PlayerSlider(timeElapsed: Duration?, episodeDuration: Duration?) { + Column(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth()) { + Text( + text = "${timeElapsed?.formatString()} • ${episodeDuration?.formatString()}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + val sliderValue = (timeElapsed?.toSeconds() ?: 0).toFloat() + val maxRange = (episodeDuration?.toSeconds() ?: 0).toFloat() + Slider( + value = sliderValue, + valueRange = 0f..maxRange, + onValueChange = { } + ) } } @Composable private fun PlayerButtons( + isPlaying: Boolean, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, modifier: Modifier = Modifier, playerButtonSize: Dp = 72.dp, - sideButtonSize: Dp = 48.dp + sideButtonSize: Dp = 48.dp, ) { Row( modifier = modifier.fillMaxWidth(), @@ -495,37 +611,61 @@ private fun PlayerButtons( imageVector = Icons.Filled.SkipPrevious, contentDescription = stringResource(R.string.cd_skip_previous), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier ) Image( imageVector = Icons.Filled.Replay10, - contentDescription = stringResource(R.string.cd_reply10), + contentDescription = stringResource(R.string.cd_replay10), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier + .clickable { + onRewindBy(Duration.ofSeconds(10)) + } ) + if (isPlaying) { + Image( + imageVector = Icons.Rounded.PauseCircleFilled, + contentDescription = stringResource(R.string.cd_pause), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier + .size(playerButtonSize) + .semantics { role = Role.Button } + .clickable { + onPausePress() + } + ) + } else { + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier + .size(playerButtonSize) + .semantics { role = Role.Button } + .clickable { + onPlayPress() + } + ) + } Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } - ) - Image( - imageVector = Icons.Filled.Forward30, - contentDescription = stringResource(R.string.cd_forward30), + imageVector = Icons.Filled.Forward10, + contentDescription = stringResource(R.string.cd_forward10), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier + .clickable { + onAdvanceBy(Duration.ofSeconds(10)) + } ) Image( imageVector = Icons.Filled.SkipNext, contentDescription = stringResource(R.string.cd_skip_next), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier ) } @@ -557,7 +697,13 @@ fun TopAppBarPreview() { @Composable fun PlayerButtonsPreview() { JetcasterTheme { - PlayerButtons() + PlayerButtons( + isPlaying = true, + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + ) } } @@ -574,11 +720,17 @@ fun PlayerScreenPreview() { PlayerUiState( title = "Title", duration = Duration.ofHours(2), - podcastName = "Podcast" + podcastName = "Podcast", + isPlaying = false, ), displayFeatures = emptyList(), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), - onBackPress = { } + onBackPress = { }, + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + onStop = {}, ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 177104f714..580907ea9c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -29,9 +29,10 @@ import androidx.savedstate.SavedStateRegistryOwner import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore -import java.time.Duration -import kotlinx.coroutines.flow.first +import com.example.jetcaster.core.player.EpisodePlayer +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import java.time.Duration data class PlayerUiState( val title: String = "", @@ -40,15 +41,18 @@ data class PlayerUiState( val podcastName: String = "", val author: String = "", val summary: String = "", - val podcastImageUrl: String = "" + val podcastImageUrl: String = "", + val isPlaying: Boolean = false, + val timeElapsed: Duration? = null, ) /** * ViewModel that handles the business logic and screen state of the Player screen */ class PlayerViewModel( - episodeStore: EpisodeStore, - podcastStore: PodcastStore, + episodeStore: EpisodeStore = Graph.episodeStore, + podcastStore: PodcastStore = Graph.podcastStore, + private val episodePlayer: EpisodePlayer = Graph.episodePlayer, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -61,25 +65,55 @@ class PlayerViewModel( init { viewModelScope.launch { - val episode = episodeStore.episodeWithUri(episodeUri).first() - val podcast = podcastStore.podcastWithUri(episode.podcastUri).first() - uiState = PlayerUiState( - title = episode.title, - duration = episode.duration, - podcastName = podcast.title, - summary = episode.summary ?: "", - podcastImageUrl = podcast.imageUrl ?: "" - ) + combine( + episodeStore.episodeAndPodcastWithUri(episodeUri), + episodePlayer.playerState + ) { episodeToPlayer, playerState -> + episodePlayer.currentEpisode = episodeToPlayer.episode + PlayerUiState( + title = episodeToPlayer.episode.title, + duration = episodeToPlayer.episode.duration, + podcastName = episodeToPlayer.episode.title, + summary = episodeToPlayer.episode.summary ?: "", + podcastImageUrl = episodeToPlayer.podcast.imageUrl ?: "", + isPlaying = playerState.isPlaying, + timeElapsed = playerState.timeElapsed + ) + }.collect { + uiState = it + } } } + fun onPlay() { + episodePlayer.play() + } + + fun onPause() { + episodePlayer.pause() + } + + fun onStop() { + episodePlayer.stop() + } + + fun onAdvanceBy(duration: Duration) { + episodePlayer.advanceBy(duration) + } + + fun onRewindBy(duration: Duration) { + episodePlayer.rewindBy(duration) + } + /** - * Factory for PlayerViewModel that takes EpisodeStore and PodcastStore as a dependency + * Factory for PlayerViewModel that takes EpisodeStore, PodcastStore and EpisodePlayer as a + * dependency */ companion object { fun provideFactory( episodeStore: EpisodeStore = Graph.episodeStore, podcastStore: PodcastStore = Graph.podcastStore, + episodePlayer: EpisodePlayer = Graph.episodePlayer, owner: SavedStateRegistryOwner, defaultArgs: Bundle? = null, ): AbstractSavedStateViewModelFactory = @@ -90,7 +124,7 @@ class PlayerViewModel( modelClass: Class, handle: SavedStateHandle ): T { - return PlayerViewModel(episodeStore, podcastStore, handle) as T + return PlayerViewModel(episodeStore, podcastStore, episodePlayer, handle) as T } } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt index d22c5c1f63..46fabe361a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt @@ -502,11 +502,10 @@ fun JetcasterTheme( else -> lightScheme } val view = LocalView.current - val statusBarColor = colorScheme.surface if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = statusBarColor.toArgb() + window.statusBarColor = Color.Transparent.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index 08bb67ca9c..cac531de11 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -40,20 +40,21 @@ %1$s • %2$d mins - Search Account Add Back - More - Play - Skip previous - Reply 10 seconds - Forward 30 seconds - Skip next - Unfollow Follow Following + Forward 10 seconds + More Not following + Pause + Play + Replay 10 seconds + Search Selected category + Skip next + Skip previous + Unfollow diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt index 4c4703a3b8..5943be5a7c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt @@ -36,6 +36,15 @@ abstract class EpisodesDao : BaseDao { ) abstract fun episode(uri: String): Flow + @Query( + """ + SELECT episodes.* FROM episodes + INNER JOIN podcasts ON episodes.podcast_uri = podcasts.uri + WHERE episodes.uri = :uri + """ + ) + abstract fun episodeAndPodcast(uri: String): Flow + @Query( """ SELECT * FROM episodes WHERE podcast_uri = :podcastUri diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt index 52e217a7c0..bc06f39dc6 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt @@ -31,13 +31,14 @@ 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.PodcastsRepository +import com.example.jetcaster.core.player.MockEpisodePlayer import com.rometools.rome.io.SyndFeedInput -import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.LoggingEventListener +import java.io.File /** * A very simple global singleton dependency graph. @@ -55,6 +56,10 @@ object Graph { private val syndFeedInput by lazy { SyndFeedInput() } + val episodePlayer by lazy { + MockEpisodePlayer(mainDispatcher) + } + val podcastRepository by lazy { PodcastsRepository( podcastsFetcher = podcastFetcher, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt index ef01e063a4..26af92e97c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt @@ -27,6 +27,11 @@ interface EpisodeStore { */ fun episodeWithUri(episodeUri: String): Flow + /** + * Returns a flow containing the episode and corresponding podcast given an [episodeUri]. + */ + fun episodeAndPodcastWithUri(episodeUri: String): Flow + /** * Returns a flow containing the list of episodes associated with the podcast with the * given [podcastUri]. @@ -68,6 +73,9 @@ class LocalEpisodeStore( return episodesDao.episode(episodeUri) } + override fun episodeAndPodcastWithUri(episodeUri: String): Flow = + episodesDao.episodeAndPodcast(episodeUri) + /** * Returns a flow containing the list of episodes associated with the podcast with the * given [podcastUri]. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt new file mode 100644 index 0000000000..a09ecd1438 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -0,0 +1,64 @@ +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.data.database.model.Episode +import kotlinx.coroutines.flow.StateFlow +import java.time.Duration + +data class EpisodePlayerState( + val currentEpisode: Episode? = null, + 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 + + /** + * Gets the current episode playing, or to be played, by this player. + */ + var currentEpisode: Episode? + + /** + * Plays the current episode + */ + fun play() + + /** + * 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) +} diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt new file mode 100644 index 0000000000..35805390ba --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -0,0 +1,125 @@ +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.data.database.model.Episode +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 +import java.time.Duration +import kotlin.reflect.KProperty + +class MockEpisodePlayer( + private val mainDispatcher: CoroutineDispatcher +) : EpisodePlayer { + + private val _playerState = MutableStateFlow(EpisodePlayerState()) + private val _currentEpisode = MutableStateFlow(null) + private val isPlaying = MutableStateFlow(false) + private val timeElapsed = MutableStateFlow(Duration.ZERO) + private val coroutineScope = CoroutineScope(mainDispatcher) + + private var timerJob: Job? = null + init { + coroutineScope.launch { + // Combine streams here + combine( + _currentEpisode, + isPlaying, + timeElapsed + ) { currentEpisode, isPlaying, timeElapsed -> + EpisodePlayerState( + currentEpisode = currentEpisode, + isPlaying = isPlaying, + timeElapsed = timeElapsed + ) + }.catch { + // TODO handle error state + throw it + }.collect { + _playerState.value = it + } + } + } + + override val playerState: StateFlow = _playerState.asStateFlow() + + override var currentEpisode: Episode? by _currentEpisode + + 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(1000L) + timeElapsed.update { it + Duration.ofSeconds(1) } + } + + // Stop playing + timeElapsed.value = Duration.ZERO + isPlaying.value = false + } + } + + 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 next() { + TODO("Not yet implemented") + } + + override fun previous() { + TODO("Not yet implemented") + } +} + +// Used to enable property delegation +private operator fun MutableStateFlow.setValue( + thisObj: Any?, + property: KProperty<*>, + value: T +) { + this.value = value +} + +private operator fun MutableStateFlow.getValue(thisObj: Any?, property: KProperty<*>): T = + this.value diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt index 85c0da4c42..ec415eaa3c 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt @@ -28,8 +28,19 @@ class TestEpisodeStore : EpisodeStore { private val episodesFlow = MutableStateFlow>(listOf()) override fun episodeWithUri(episodeUri: String): Flow = - episodesFlow.map { - it.first { it.uri == episodeUri } + episodesFlow.map { episodes -> + episodes.first { it.uri == episodeUri } + } + + override fun episodeAndPodcastWithUri(episodeUri: String): Flow = + episodesFlow.map { episodes -> + val e = episodes.first { + it.uri == episodeUri + } + EpisodeToPodcast().apply { + episode = e + _podcasts = emptyList() + } } override fun episodesInPodcast(podcastUri: String, limit: Int): Flow> = From a59f18607d31adb645ea9aad000cb13a2e55fdea Mon Sep 17 00:00:00 2001 From: arriolac Date: Sat, 23 Mar 2024 00:04:22 +0000 Subject: [PATCH 041/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/jetcaster/ui/home/Home.kt | 5 +++++ .../jetcaster/ui/player/PlayerScreen.kt | 2 +- .../jetcaster/ui/player/PlayerViewModel.kt | 2 +- .../example/jetcaster/core/data/di/Graph.kt | 2 +- .../jetcaster/core/player/EpisodePlayer.kt | 18 ++++++++++++++++- .../core/player/MockEpisodePlayer.kt | 20 +++++++++++++++++-- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 6be350dd65..f9eee6a30a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -63,6 +63,8 @@ import androidx.compose.material3.TopAppBar 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.snapshotFlow import androidx.compose.ui.Alignment @@ -70,6 +72,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 2a2027c645..3d14eafd84 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -561,7 +561,7 @@ private fun PodcastInformation( } } -fun Duration.formatString() : String { +fun Duration.formatString(): String { val minutes = this.toMinutes().toString().padStart(2, '0') val secondsLeft = (this.toSeconds() % 60).toString().padStart(2, '0') return "$minutes:$secondsLeft" diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 580907ea9c..27a5b0069f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -30,9 +30,9 @@ import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.player.EpisodePlayer +import java.time.Duration import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch -import java.time.Duration data class PlayerUiState( val title: String = "", diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt index bc06f39dc6..c53f2f024d 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt @@ -33,12 +33,12 @@ import com.example.jetcaster.core.data.repository.LocalPodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.core.player.MockEpisodePlayer import com.rometools.rome.io.SyndFeedInput +import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.LoggingEventListener -import java.io.File /** * A very simple global singleton dependency graph. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt index a09ecd1438..c3f80e2e4e 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -1,8 +1,24 @@ +/* + * 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.data.database.model.Episode -import kotlinx.coroutines.flow.StateFlow import java.time.Duration +import kotlinx.coroutines.flow.StateFlow data class EpisodePlayerState( val currentEpisode: Episode? = null, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 35805390ba..855953a46c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -1,6 +1,24 @@ +/* + * 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.data.database.model.Episode +import java.time.Duration +import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -13,8 +31,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.time.Duration -import kotlin.reflect.KProperty class MockEpisodePlayer( private val mainDispatcher: CoroutineDispatcher From 5fa6d0693b70ce1c093d311166bbac557c2747cf Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 22 Mar 2024 19:42:58 -0700 Subject: [PATCH 042/143] Add support for queueing episodes. --- .../com/example/jetcaster/ui/home/Home.kt | 55 +++++---- .../jetcaster/ui/home/HomeViewModel.kt | 9 +- .../ui/home/category/PodcastCategory.kt | 11 +- .../jetcaster/ui/home/discover/Discover.kt | 5 +- .../jetcaster/ui/home/library/Library.kt | 4 +- .../jetcaster/ui/player/PlayerScreen.kt | 107 +++++++++++++----- .../jetcaster/ui/player/PlayerViewModel.kt | 42 ++++--- Jetcaster/app/src/main/res/values/strings.xml | 1 + .../core/data/model/PlayerEpisode.kt | 23 ++++ .../jetcaster/core/player/EpisodePlayer.kt | 11 +- .../core/player/MockEpisodePlayer.kt | 43 +++++-- 11 files changed, 223 insertions(+), 88 deletions(-) create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index f9eee6a30a..7d967a2efd 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -53,6 +53,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api 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.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabPosition @@ -123,6 +126,7 @@ fun Home( navigateToPlayer = navigateToPlayer, onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, + onQueuePodcast = viewModel::onQueuePodcast, modifier = Modifier.fillMaxSize() ) } @@ -187,7 +191,8 @@ fun Home( onCategorySelected: (Category) -> Unit, navigateToPlayer: (String) -> Unit, onTogglePodcastFollowed: (String) -> Unit, - onLibraryPodcastSelected: (Podcast?) -> Unit + onLibraryPodcastSelected: (Podcast?) -> Unit, + onQueuePodcast: (EpisodeToPodcast) -> Unit, ) { // Effect that changes the home category selection when there are no subscribed podcasts LaunchedEffect(key1 = featuredPodcasts) { @@ -196,30 +201,25 @@ fun Home( } } - Column( + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( modifier = modifier.windowInsetsPadding( WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) - ) - ) { - // We dynamically theme this sub-section of the layout to match the selected - // 'top podcast' - - val scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) - - // Top Bar - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = scrimColor) - ) { - // Draw a scrim over the status bar which matches the app bar + ), + topBar = { HomeAppBar( backgroundColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth() ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) } - + ) { contentPadding -> // Main Content + val scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) HomeContent( featuredPodcasts = featuredPodcasts, isRefreshing = isRefreshing, @@ -229,12 +229,19 @@ fun Home( podcastCategoryFilterResult = podcastCategoryFilterResult, libraryEpisodes = libraryEpisodes, scrimColor = scrimColor, + modifier = Modifier.padding(contentPadding), onPodcastUnfollowed = onPodcastUnfollowed, onHomeCategorySelected = onHomeCategorySelected, onCategorySelected = onCategorySelected, navigateToPlayer = navigateToPlayer, onTogglePodcastFollowed = onTogglePodcastFollowed, - onLibraryPodcastSelected = onLibraryPodcastSelected + onLibraryPodcastSelected = onLibraryPodcastSelected, + onQueuePodcast = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onQueuePodcast(it) + } ) } } @@ -256,7 +263,8 @@ private fun HomeContent( onCategorySelected: (Category) -> Unit, navigateToPlayer: (String) -> Unit, onTogglePodcastFollowed: (String) -> Unit, - onLibraryPodcastSelected: (Podcast?) -> Unit + onLibraryPodcastSelected: (Podcast?) -> Unit, + onQueuePodcast: (EpisodeToPodcast) -> Unit, ) { val pagerState = rememberPagerState { featuredPodcasts.size } LaunchedEffect(pagerState, featuredPodcasts) { @@ -302,7 +310,8 @@ private fun HomeContent( HomeCategory.Library -> { libraryItems( episodes = libraryEpisodes, - navigateToPlayer = navigateToPlayer + navigateToPlayer = navigateToPlayer, + onQueuePodcast = onQueuePodcast ) } @@ -312,7 +321,8 @@ private fun HomeContent( podcastCategoryFilterResult = podcastCategoryFilterResult, navigateToPlayer = navigateToPlayer, onCategorySelected = onCategorySelected, - onTogglePodcastFollowed = onTogglePodcastFollowed + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueuePodcast = onQueuePodcast ) } } @@ -523,7 +533,8 @@ fun PreviewHomeContent() { navigateToPlayer = {}, onHomeCategorySelected = {}, onTogglePodcastFollowed = {}, - onLibraryPodcastSelected = {} + onLibraryPodcastSelected = {}, + onQueuePodcast = {} ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index b7d29b3242..bc4dc29a0a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -28,9 +28,11 @@ import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.data.model.toPlayerEpisode 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.player.EpisodePlayer import com.example.jetcaster.util.combine import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf @@ -52,7 +54,8 @@ class HomeViewModel( private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase = Graph.podcastCategoryFilterUseCase, private val filterableCategoriesUseCase: FilterableCategoriesUseCase = - Graph.filterableCategoriesUseCase + Graph.filterableCategoriesUseCase, + private val episodePlayer: EpisodePlayer = Graph.episodePlayer ) : ViewModel() { // Holds our currently selected podcast in the library private val selectedLibraryPodcast = MutableStateFlow(null) @@ -162,6 +165,10 @@ class HomeViewModel( fun onLibraryPodcastSelected(podcast: Podcast?) { selectedLibraryPodcast.value = podcast } + + fun onQueuePodcast(episodeToPodcast: EpisodeToPodcast) { + episodePlayer.addToQueue(episodeToPodcast.toPlayerEpisode()) + } } enum class HomeCategory { diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index e9a8f3c829..5a6db831cb 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -80,6 +80,7 @@ fun LazyListScope.podcastCategory( topPodcasts: List, episodes: List, navigateToPlayer: (String) -> Unit, + onQueuePodcast: (EpisodeToPodcast) -> Unit, onTogglePodcastFollowed: (String) -> Unit, ) { item { @@ -91,6 +92,7 @@ fun LazyListScope.podcastCategory( episode = item.episode, podcast = item.podcast, onClick = navigateToPlayer, + onQueuePodcast = onQueuePodcast, modifier = Modifier.fillParentMaxWidth() ) } @@ -113,6 +115,7 @@ fun EpisodeListItem( episode: Episode, podcast: Podcast, onClick: (String) -> Unit, + onQueuePodcast: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, showDivider: Boolean = true, ) { @@ -241,7 +244,12 @@ fun EpisodeListItem( ) IconButton( - onClick = { /* TODO */ }, + onClick = { + onQueuePodcast(EpisodeToPodcast().apply { + this.episode = episode + this._podcasts = listOf(podcast) + }) + }, modifier = Modifier.constrainAs(addPlaylist) { end.linkTo(overflow.start) centerVerticallyTo(playIcon) @@ -358,6 +366,7 @@ fun PreviewEpisodeListItem() { episode = PreviewEpisodes[0], podcast = PreviewPodcasts[0], onClick = { }, + onQueuePodcast = { }, modifier = Modifier.fillMaxWidth() ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index ebf471fd95..3507764068 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.designsystem.theme.Keyline1 @@ -49,6 +50,7 @@ fun LazyListScope.discoverItems( navigateToPlayer: (String) -> Unit, onCategorySelected: (Category) -> Unit, onTogglePodcastFollowed: (String) -> Unit, + onQueuePodcast: (EpisodeToPodcast) -> Unit, ) { if (filterableCategoriesModel.isEmpty) { // TODO: empty state @@ -71,7 +73,8 @@ fun LazyListScope.discoverItems( topPodcasts = podcastCategoryFilterResult.topPodcasts, episodes = podcastCategoryFilterResult.episodes, navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueuePodcast = onQueuePodcast, ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index 4d505051bb..1c2716907d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -31,7 +31,8 @@ import com.example.jetcaster.ui.home.category.EpisodeListItem fun LazyListScope.libraryItems( episodes: List, - navigateToPlayer: (String) -> Unit + navigateToPlayer: (String) -> Unit, + onQueuePodcast: (EpisodeToPodcast) -> Unit ) { if (episodes.isEmpty()) { // TODO: Empty state @@ -57,6 +58,7 @@ fun LazyListScope.libraryItems( episode = item.episode, podcast = item.podcast, onClick = navigateToPlayer, + onQueuePodcast = onQueuePodcast, modifier = Modifier.fillParentMaxWidth(), showDivider = index != 0 ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 3d14eafd84..6da07c69ac 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -86,6 +86,8 @@ import androidx.window.layout.FoldingFeature import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.isBookPosture import com.example.jetcaster.util.isSeparatingPosture @@ -116,7 +118,9 @@ fun PlayerScreen( onPausePress = viewModel::onPause, onAdvanceBy = viewModel::onAdvanceBy, onRewindBy = viewModel::onRewindBy, - onStop = viewModel::onStop + onStop = viewModel::onStop, + onNext = viewModel::onNext, + onPrevious = viewModel::onPrevious, ) } @@ -134,6 +138,8 @@ private fun PlayerScreen( onAdvanceBy: (Duration) -> Unit, onRewindBy: (Duration) -> Unit, onStop: () -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, modifier: Modifier = Modifier ) { DisposableEffect(Unit) { @@ -142,7 +148,7 @@ private fun PlayerScreen( } } Surface(modifier) { - if (uiState.podcastName.isNotEmpty()) { + if (uiState.episodePlayerState.currentEpisode != null) { PlayerContent( uiState, windowSizeClass, @@ -151,7 +157,9 @@ private fun PlayerScreen( onPlayPress, onPausePress, onAdvanceBy, - onRewindBy + onRewindBy, + onNext, + onPrevious, ) } else { FullScreenLoading() @@ -169,6 +177,8 @@ fun PlayerContent( onPausePress: () -> Unit, onAdvanceBy: (Duration) -> Unit, onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, modifier: Modifier = Modifier ) { val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() @@ -203,7 +213,9 @@ fun PlayerContent( onPlayPress = onPlayPress, onPausePress = onPausePress, onAdvanceBy = onAdvanceBy, - onRewindBy = onRewindBy + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, ) }, strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), @@ -233,7 +245,9 @@ fun PlayerContent( onPlayPress = onPlayPress, onPausePress = onPausePress, onAdvanceBy = onAdvanceBy, - onRewindBy = onRewindBy + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, ) }, strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), @@ -249,6 +263,8 @@ fun PlayerContent( onPausePress, onAdvanceBy = onAdvanceBy, onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, modifier, ) } @@ -265,8 +281,12 @@ private fun PlayerContentRegular( onPausePress: () -> Unit, onAdvanceBy: (Duration) -> Unit, onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, modifier: Modifier = Modifier ) { + val playerEpisode = uiState.episodePlayerState + val currentEpisode = playerEpisode.currentEpisode ?: return Column( modifier = modifier .fillMaxSize() @@ -285,26 +305,29 @@ private fun PlayerContentRegular( ) { Spacer(modifier = Modifier.weight(1f)) PlayerImage( - podcastImageUrl = uiState.podcastImageUrl, + podcastImageUrl = currentEpisode.podcastImageUrl, modifier = Modifier.weight(10f) ) Spacer(modifier = Modifier.height(32.dp)) - PodcastDescription(uiState.title, uiState.podcastName) + PodcastDescription(currentEpisode.title, currentEpisode.podcastName) Spacer(modifier = Modifier.height(32.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(10f) ) { PlayerSlider( - timeElapsed = uiState.timeElapsed, - episodeDuration = uiState.duration + timeElapsed = playerEpisode.timeElapsed, + episodeDuration = currentEpisode.duration ) PlayerButtons( - isPlaying = uiState.isPlaying, + hasNext = playerEpisode.queue.isNotEmpty(), + isPlaying = playerEpisode.isPlaying, onPlayPress = onPlayPress, onPausePress = onPausePress, onAdvanceBy = onAdvanceBy, onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, Modifier.padding(vertical = 8.dp) ) } @@ -322,6 +345,7 @@ private fun PlayerContentTableTopTop( modifier: Modifier = Modifier ) { // Content for the top part of the screen + val episode = uiState.episodePlayerState.currentEpisode ?: return Column( modifier = modifier .fillMaxWidth() @@ -338,7 +362,7 @@ private fun PlayerContentTableTopTop( .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - PlayerImage(uiState.podcastImageUrl) + PlayerImage(episode.podcastImageUrl) } } @@ -353,8 +377,12 @@ private fun PlayerContentTableTopBottom( onPausePress: () -> Unit, onAdvanceBy: (Duration) -> Unit, onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, modifier: Modifier = Modifier ) { + val episodePlayerState = uiState.episodePlayerState + val episode = uiState.episodePlayerState.currentEpisode ?: return // Content for the table part of the screen Column( modifier = modifier @@ -368,8 +396,8 @@ private fun PlayerContentTableTopBottom( ) { TopAppBar(onBackPress = onBackPress) PodcastDescription( - title = uiState.title, - podcastName = uiState.podcastName, + title = episode.title, + podcastName = episode.podcastName, titleTextStyle = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.weight(0.5f)) @@ -378,17 +406,20 @@ private fun PlayerContentTableTopBottom( modifier = Modifier.weight(10f) ) { PlayerButtons( - isPlaying = uiState.isPlaying, + hasNext = episodePlayerState.queue.isNotEmpty(), + isPlaying = episodePlayerState.isPlaying, onPlayPress = onPlayPress, onPausePress = onPausePress, playerButtonSize = 92.dp, onAdvanceBy = onAdvanceBy, onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, modifier = Modifier.padding(top = 8.dp) ) PlayerSlider( - timeElapsed = uiState.timeElapsed, - episodeDuration = uiState.duration + timeElapsed = episodePlayerState.timeElapsed, + episodeDuration = episode.duration ) } } @@ -402,6 +433,7 @@ private fun PlayerContentBookStart( uiState: PlayerUiState, modifier: Modifier = Modifier ) { + val episode = uiState.episodePlayerState.currentEpisode ?: return Column( modifier = modifier .fillMaxSize() @@ -415,9 +447,9 @@ private fun PlayerContentBookStart( ) { Spacer(modifier = Modifier.height(32.dp)) PodcastInformation( - uiState.title, - uiState.podcastName, - uiState.summary + episode.title, + episode.podcastName, + episode.summary ) Spacer(modifier = Modifier.height(32.dp)) } @@ -433,8 +465,12 @@ private fun PlayerContentBookEnd( onPausePress: () -> Unit, onAdvanceBy: (Duration) -> Unit, onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, modifier: Modifier = Modifier ) { + val episodePlayerState = uiState.episodePlayerState + val episode = episodePlayerState.currentEpisode ?: return Column( modifier = modifier .fillMaxSize() @@ -443,21 +479,24 @@ private fun PlayerContentBookEnd( verticalArrangement = Arrangement.SpaceAround, ) { PlayerImage( - podcastImageUrl = uiState.podcastImageUrl, + podcastImageUrl = episode.podcastImageUrl, modifier = Modifier .padding(vertical = 16.dp) .weight(1f) ) PlayerSlider( - timeElapsed = uiState.timeElapsed, - episodeDuration = uiState.duration + timeElapsed = episodePlayerState.timeElapsed, + episodeDuration = episode.duration ) PlayerButtons( - isPlaying = uiState.isPlaying, + hasNext = episodePlayerState.queue.isNotEmpty(), + isPlaying = episodePlayerState.isPlaying, onPlayPress = onPlayPress, onPausePress = onPausePress, onAdvanceBy = onAdvanceBy, onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, Modifier.padding(vertical = 8.dp) ) } @@ -589,11 +628,14 @@ private fun PlayerSlider(timeElapsed: Duration?, episodeDuration: Duration?) { @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, playerButtonSize: Dp = 72.dp, sideButtonSize: Dp = 48.dp, @@ -613,6 +655,7 @@ private fun PlayerButtons( contentScale = ContentScale.Fit, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier + .clickable(enabled = isPlaying, onClick = onPrevious) ) Image( imageVector = Icons.Filled.Replay10, @@ -667,6 +710,7 @@ private fun PlayerButtons( contentScale = ContentScale.Fit, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier + .clickable(enabled = hasNext, onClick = onNext) ) } } @@ -698,11 +742,14 @@ fun TopAppBarPreview() { fun PlayerButtonsPreview() { JetcasterTheme { PlayerButtons( + hasNext = false, isPlaying = true, onPlayPress = {}, onPausePress = {}, onAdvanceBy = {}, onRewindBy = {}, + onNext = {}, + onPrevious = {}, ) } } @@ -718,10 +765,14 @@ fun PlayerScreenPreview() { BoxWithConstraints { PlayerScreen( PlayerUiState( - title = "Title", - duration = Duration.ofHours(2), - podcastName = "Podcast", - isPlaying = false, + episodePlayerState = EpisodePlayerState( + currentEpisode = PlayerEpisode( + title = "Title", + duration = Duration.ofHours(2), + podcastName = "Podcast", + ), + isPlaying = false, + ), ), displayFeatures = emptyList(), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), @@ -731,6 +782,8 @@ fun PlayerScreenPreview() { onAdvanceBy = {}, onRewindBy = {}, onStop = {}, + onNext = {}, + onPrevious = {} ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 27a5b0069f..dede3dcda7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -27,28 +27,25 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.player.EpisodePlayer import java.time.Duration -import kotlinx.coroutines.flow.combine +import com.example.jetcaster.core.player.EpisodePlayerState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch data class PlayerUiState( - val title: String = "", - val subTitle: String = "", - val duration: Duration? = null, - val podcastName: String = "", - val author: String = "", - val summary: String = "", - val podcastImageUrl: String = "", - val isPlaying: Boolean = false, - val timeElapsed: Duration? = null, + val episodePlayerState: EpisodePlayerState = EpisodePlayerState() ) /** * ViewModel that handles the business logic and screen state of the Player screen */ +@OptIn(ExperimentalCoroutinesApi::class) class PlayerViewModel( episodeStore: EpisodeStore = Graph.episodeStore, podcastStore: PodcastStore = Graph.podcastStore, @@ -65,20 +62,11 @@ class PlayerViewModel( init { viewModelScope.launch { - combine( - episodeStore.episodeAndPodcastWithUri(episodeUri), + episodeStore.episodeAndPodcastWithUri(episodeUri).flatMapConcat { + episodePlayer.currentEpisode = it.toPlayerEpisode() episodePlayer.playerState - ) { episodeToPlayer, playerState -> - episodePlayer.currentEpisode = episodeToPlayer.episode - PlayerUiState( - title = episodeToPlayer.episode.title, - duration = episodeToPlayer.episode.duration, - podcastName = episodeToPlayer.episode.title, - summary = episodeToPlayer.episode.summary ?: "", - podcastImageUrl = episodeToPlayer.podcast.imageUrl ?: "", - isPlaying = playerState.isPlaying, - timeElapsed = playerState.timeElapsed - ) + }.map { + PlayerUiState(episodePlayerState = it) }.collect { uiState = it } @@ -97,6 +85,14 @@ class PlayerViewModel( episodePlayer.stop() } + fun onPrevious() { + episodePlayer.previous() + } + + fun onNext() { + episodePlayer.next() + } + fun onAdvanceBy(duration: Duration) { episodePlayer.advanceBy(duration) } diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index cac531de11..919d4dc6a1 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -56,5 +56,6 @@ Skip next Skip previous Unfollow + Episode added to your queue diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt new file mode 100644 index 0000000000..ef1386e4ad --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -0,0 +1,23 @@ +package com.example.jetcaster.core.data.model + +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import java.time.Duration + +data class PlayerEpisode( + val title: String = "", + val subTitle: String = "", + val duration: Duration? = null, + val podcastName: String = "", + val author: String = "", + val summary: String = "", + val podcastImageUrl: String = "", +) + +fun EpisodeToPodcast.toPlayerEpisode() : PlayerEpisode = + PlayerEpisode( + title = episode.title, + duration = episode.duration, + podcastName = podcast.title, + summary = episode.summary ?: "", + podcastImageUrl = podcast.imageUrl ?: "", + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt index c3f80e2e4e..1182fba5f5 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -16,12 +16,13 @@ package com.example.jetcaster.core.player -import com.example.jetcaster.core.data.database.model.Episode -import java.time.Duration +import com.example.jetcaster.core.data.model.PlayerEpisode import kotlinx.coroutines.flow.StateFlow +import java.time.Duration data class EpisodePlayerState( - val currentEpisode: Episode? = null, + val currentEpisode: PlayerEpisode? = null, + val queue: List = emptyList(), val isPlaying: Boolean = false, val timeElapsed: Duration = Duration.ZERO, ) @@ -40,7 +41,9 @@ interface EpisodePlayer { /** * Gets the current episode playing, or to be played, by this player. */ - var currentEpisode: Episode? + var currentEpisode: PlayerEpisode? + + fun addToQueue(episode: PlayerEpisode) /** * Plays the current episode diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 855953a46c..100893ea84 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -16,9 +16,9 @@ package com.example.jetcaster.core.player -import com.example.jetcaster.core.data.database.model.Episode import java.time.Duration import kotlin.reflect.KProperty +import com.example.jetcaster.core.data.model.PlayerEpisode import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -37,7 +37,8 @@ class MockEpisodePlayer( ) : EpisodePlayer { private val _playerState = MutableStateFlow(EpisodePlayerState()) - private val _currentEpisode = MutableStateFlow(null) + private val _currentEpisode = MutableStateFlow(null) + private val queue = MutableStateFlow>(emptyList()) private val isPlaying = MutableStateFlow(false) private val timeElapsed = MutableStateFlow(Duration.ZERO) private val coroutineScope = CoroutineScope(mainDispatcher) @@ -48,11 +49,13 @@ class MockEpisodePlayer( // Combine streams here combine( _currentEpisode, + queue, isPlaying, timeElapsed - ) { currentEpisode, isPlaying, timeElapsed -> + ) { currentEpisode, queue, isPlaying, timeElapsed -> EpisodePlayerState( currentEpisode = currentEpisode, + queue = queue, isPlaying = isPlaying, timeElapsed = timeElapsed ) @@ -67,7 +70,12 @@ class MockEpisodePlayer( override val playerState: StateFlow = _playerState.asStateFlow() - override var currentEpisode: Episode? by _currentEpisode + override var currentEpisode: PlayerEpisode? by _currentEpisode + override fun addToQueue(episode: PlayerEpisode) { + queue.update { + it + episode + } + } override fun play() { // Do nothing if already playing @@ -85,9 +93,13 @@ class MockEpisodePlayer( timeElapsed.update { it + Duration.ofSeconds(1) } } - // Stop playing - timeElapsed.value = Duration.ZERO + // Once done playing, see if isPlaying.value = false + timeElapsed.value = Duration.ZERO + + if (hasNext()) { + next() + } } } @@ -120,11 +132,26 @@ class MockEpisodePlayer( } override fun next() { - TODO("Not yet implemented") + val q = queue.value + if (q.isEmpty()) { + return + } + + timeElapsed.value = Duration.ZERO + val nextEpisode = q[0] + currentEpisode = nextEpisode + queue.value = q - nextEpisode } override fun previous() { - TODO("Not yet implemented") + timeElapsed.value = Duration.ZERO + isPlaying.value = false + timerJob?.cancel() + timerJob = null + } + + private fun hasNext(): Boolean { + return queue.value.isNotEmpty() } } From 08933f403ca4288c638fb625c0e4e351b4d8e7e7 Mon Sep 17 00:00:00 2001 From: arriolac Date: Sat, 23 Mar 2024 02:47:11 +0000 Subject: [PATCH 043/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/home/category/PodcastCategory.kt | 10 ++++++---- .../jetcaster/ui/player/PlayerViewModel.kt | 2 +- .../jetcaster/core/data/model/PlayerEpisode.kt | 18 +++++++++++++++++- .../jetcaster/core/player/EpisodePlayer.kt | 2 +- .../jetcaster/core/player/MockEpisodePlayer.kt | 2 +- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index 5a6db831cb..f37bafd9ee 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -245,10 +245,12 @@ fun EpisodeListItem( IconButton( onClick = { - onQueuePodcast(EpisodeToPodcast().apply { - this.episode = episode - this._podcasts = listOf(podcast) - }) + onQueuePodcast( + EpisodeToPodcast().apply { + this.episode = episode + this._podcasts = listOf(podcast) + } + ) }, modifier = Modifier.constrainAs(addPlaylist) { end.linkTo(overflow.start) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index dede3dcda7..2acef025e1 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -31,8 +31,8 @@ import com.example.jetcaster.core.data.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.player.EpisodePlayer -import java.time.Duration import com.example.jetcaster.core.player.EpisodePlayerState +import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt index ef1386e4ad..19ca707b1b 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -1,3 +1,19 @@ +/* + * 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.model import com.example.jetcaster.core.data.database.model.EpisodeToPodcast @@ -13,7 +29,7 @@ data class PlayerEpisode( val podcastImageUrl: String = "", ) -fun EpisodeToPodcast.toPlayerEpisode() : PlayerEpisode = +fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = PlayerEpisode( title = episode.title, duration = episode.duration, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt index 1182fba5f5..73b275dfb6 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -17,8 +17,8 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.data.model.PlayerEpisode -import kotlinx.coroutines.flow.StateFlow import java.time.Duration +import kotlinx.coroutines.flow.StateFlow data class EpisodePlayerState( val currentEpisode: PlayerEpisode? = null, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 100893ea84..34662b24da 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -16,9 +16,9 @@ package com.example.jetcaster.core.player +import com.example.jetcaster.core.data.model.PlayerEpisode import java.time.Duration import kotlin.reflect.KProperty -import com.example.jetcaster.core.data.model.PlayerEpisode import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job From 2bbed3cc4cffa967a7d9cbb862921e454839499a Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 25 Mar 2024 22:01:59 +0000 Subject: [PATCH 044/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 7d967a2efd..7ca57257c4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -66,7 +66,6 @@ import androidx.compose.material3.TopAppBar 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.snapshotFlow @@ -75,9 +74,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow From 3d221a2870f9658c06e4d7d19a5bd3a10ef87e54 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 26 Mar 2024 11:03:31 -0700 Subject: [PATCH 045/143] Add javadoc for episodeAndPodcast. --- .../example/jetcaster/core/data/database/dao/EpisodesDao.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt index 5943be5a7c..23456f773b 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt @@ -40,10 +40,10 @@ abstract class EpisodesDao : BaseDao { """ SELECT episodes.* FROM episodes INNER JOIN podcasts ON episodes.podcast_uri = podcasts.uri - WHERE episodes.uri = :uri + WHERE episodes.uri = :episodeUri """ ) - abstract fun episodeAndPodcast(uri: String): Flow + abstract fun episodeAndPodcast(episodeUri: String): Flow @Query( """ From 4270387e26cc471b08e6750522406e91e5f22c86 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Wed, 27 Mar 2024 09:50:25 +0900 Subject: [PATCH 046/143] Move the classes defining spaces, margins, and paddings in the app to Space.kt --- .../example/jetcaster/tv/model/EpisodeList.kt | 3 +- .../example/jetcaster/tv/ui/JetcasterApp.kt | 45 +------------- .../jetcaster/tv/ui/JetcasterAppState.kt | 6 +- .../jetcaster/tv/ui/component/Catalog.kt | 2 +- .../jetcaster/tv/ui/component/ErrorState.kt | 2 +- .../tv/ui/discover/DiscoverScreen.kt | 2 +- .../jetcaster/tv/ui/library/LibraryScreen.kt | 2 +- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 2 +- .../example/jetcaster/tv/ui/theme/Space.kt | 62 +++++++++++++++++++ 9 files changed, 74 insertions(+), 52 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt index c40bc87261..150a0de1b5 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -16,6 +16,7 @@ package com.example.jetcaster.tv.model +import androidx.compose.runtime.Immutable import com.example.jetcaster.core.data.database.model.EpisodeToPodcast - +@Immutable data class EpisodeList(val member: List) : List by member diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index e97199174a..211a786470 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -17,7 +17,6 @@ package com.example.jetcaster.tv.ui import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -30,8 +29,6 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -48,6 +45,7 @@ import com.example.jetcaster.tv.ui.podcast.PodcastScreenViewModel 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 @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable @@ -55,47 +53,6 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat Route(jetcasterAppState = jetcasterAppState) } -internal data object JetcasterAppDefaults { - val overScanMargin = OverScanMarginSettings() - val gapSettings = GapSettings() - val cardWidth = CardWidth() - val padding = PaddingSettings() -} - -data class OverScanMarginSettings( - val default: OverScanMargin = OverScanMargin(), - val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), - val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), - val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp) -) - -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) - } -} - -data class CardWidth( - val large: Dp = 268.dp, - val medium: Dp = 196.dp, - val small: Dp = 124.dp -) - -data class PaddingSettings( - val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), - val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) -) - -data class GapSettings( - val catalogItemGap: Dp = 20.dp, - val catalogSectionGap: Dp = 40.dp, -) - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun WithGlobalNavigation( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt index 600f2c6d16..23ac10a355 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -97,7 +97,8 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "podcast" - override val route = "$ROOT/{podcastUri}" + private const val PARAMETER_NAME = "podcastUri" + override val route = "$ROOT/{$PARAMETER_NAME}" } } @@ -106,7 +107,8 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "player" - override val route = "$ROOT/{episodeUri}" + private const val PARAMETER_NAME = "episodeUri" + override val route = "$ROOT/{$PARAMETER_NAME}" } } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index 045c76983f..1654769daa 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -48,7 +48,7 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo 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.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable internal fun Catalog( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt index e60359af6f..8e655a35b2 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt @@ -32,7 +32,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.tv.R -import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @OptIn(ExperimentalTvMaterial3Api::class) @Composable diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 8f7dcd4c34..3d84a42a6a 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -38,9 +38,9 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList -import com.example.jetcaster.tv.ui.JetcasterAppDefaults 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( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index e444620fb7..4f77143de5 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -36,9 +36,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.R -import com.example.jetcaster.tv.ui.JetcasterAppDefaults 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( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index 252cabab5e..6d22f685d1 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -63,11 +63,11 @@ import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList -import com.example.jetcaster.tv.ui.JetcasterAppDefaults import com.example.jetcaster.tv.ui.component.ButtonWithIcon 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.theme.JetcasterAppDefaults @Composable fun PodcastScreen( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt new file mode 100644 index 0000000000..ef5409312a --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -0,0 +1,62 @@ +/* + * 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.dp + +internal data object JetcasterAppDefaults { + val overScanMargin = OverScanMarginSettings() + val gapSettings = GapSettings() + val cardWidth = CardWidth() + val padding = PaddingSettings() +} + +internal data class OverScanMarginSettings( + val default: OverScanMargin = OverScanMargin(), + val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), + val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), + val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.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 PaddingSettings( + val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), + val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) +) + +internal data class GapSettings( + val catalogItemGap: Dp = 20.dp, + val catalogSectionGap: Dp = 40.dp, +) From 2de5753a5ab87ef67ea0c3e630823e1fe18585bd Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Wed, 27 Mar 2024 12:25:32 +0900 Subject: [PATCH 047/143] Fix the failure of tv-app:minifyReleaseWithR8 task --- Jetcaster/tv-app/proguard-rules.pro | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/Jetcaster/tv-app/proguard-rules.pro b/Jetcaster/tv-app/proguard-rules.pro index 481bb43481..08718bb52d 100644 --- a/Jetcaster/tv-app/proguard-rules.pro +++ b/Jetcaster/tv-app/proguard-rules.pro @@ -14,8 +14,37 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# Rome reflectively loads classes referenced in com/rometools/rome/rome.properties. +-adaptresourcefilecontents com/rometools/rome/rome.properties +-keep,allowobfuscation class * implements com.rometools.rome.feed.synd.Converter +-keep,allowobfuscation class * implements com.rometools.rome.io.ModuleParser +-keep,allowobfuscation class * implements com.rometools.rome.io.WireFeedParser + +# Disable warnings for missing classes from OkHttp. +-dontwarn org.conscrypt.ConscryptHostnameVerifier + +# Disable warnings for missing classes from JDOM. +-dontwarn org.jaxen.DefaultNavigator +-dontwarn org.jaxen.NamespaceContext +-dontwarn org.jaxen.VariableContext + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE From a0fb0aece8fa2f3cc68a4489886135d6d6030bf6 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Thu, 28 Mar 2024 11:28:14 +0900 Subject: [PATCH 048/143] Add search functionality to the TV app --- .../core/data/database/dao/PodcastsDao.kt | 40 +++ .../core/data/repository/PodcastStore.kt | 37 +++ .../jetcaster/tv/model/CategorySelection.kt | 27 ++ .../example/jetcaster/tv/ui/JetcasterApp.kt | 3 + .../jetcaster/tv/ui/component/Catalog.kt | 2 +- .../jetcaster/tv/ui/search/SearchScreen.kt | 256 +++++++++++++++++- .../tv/ui/search/SearchScreenViewModel.kt | 132 +++++++++ .../example/jetcaster/tv/ui/theme/Space.kt | 2 + .../tv-app/src/main/res/values/strings.xml | 1 + 9 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt index 9a5426e849..e2a754e657 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt @@ -89,6 +89,46 @@ abstract class PodcastsDao : BaseDao { limit: Int ): Flow> + @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> + + @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, + limit: Int + ): Flow> + @Query("SELECT COUNT(*) FROM podcasts") abstract suspend fun count(): Int } diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt index d7894701e2..160ce3fc2e 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -19,6 +19,7 @@ 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 @@ -46,6 +47,26 @@ interface PodcastStore { limit: Int = Int.MAX_VALUE ): Flow> + /** + * 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> + + /** + * 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, + limit: Int = Int.MAX_VALUE + ): Flow> + suspend fun togglePodcastFollowed(podcastUri: String) suspend fun unfollowPodcast(podcastUri: String) @@ -95,6 +116,22 @@ class LocalPodcastStore( return podcastDao.followedPodcastsSortedByLastEpisode(limit) } + override fun searchPodcastByTitle( + keyword: String, + limit: Int + ): Flow> { + return podcastDao.searchPodcastByTitle(keyword, limit) + } + + override fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List, + limit: Int + ): Flow> { + val categoryIdList = categories.map { it.id } + return podcastDao.searchPodcastByTitleAndCategory(keyword, categoryIdList, limit) + } + private suspend fun followPodcast(podcastUri: String) { podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri)) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt new file mode 100644 index 0000000000..0c82639585 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt @@ -0,0 +1,27 @@ +/* + * 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 + +data class CategorySelection(val category: Category, val isSelected: Boolean = false) + +@Immutable +data class CategorySelectionList( + val member: List +) : List by member diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index 211a786470..f11ff0d8c6 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -147,6 +147,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { composable(Screen.Search.route) { SearchScreen( + onPodcastSelected = { + jetcasterAppState.showPodcastDetails(it.podcast.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) .fillMaxSize() diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index 1654769daa..c3d0487d1d 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -160,7 +160,7 @@ private fun PodcastRow( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun PodcastCard( +internal fun PodcastCard( podcast: Podcast, onClick: () -> Unit, modifier: Modifier = Modifier, diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt index 5f94ed1e32..304b8429b3 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -16,11 +16,261 @@ package com.example.jetcaster.tv.ui.search +import androidx.compose.foundation.ExperimentalFoundationApi +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text2.BasicTextField2 +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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import com.example.jetcaster.tv.ui.component.NotAvailableFeature +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.lifecycle.viewmodel.compose.viewModel +import androidx.tv.foundation.lazy.grid.TvGridCells +import androidx.tv.foundation.lazy.grid.TvGridItemSpan +import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid +import androidx.tv.foundation.lazy.grid.items +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.data.database.model.Category +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +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(modifier: Modifier = Modifier) { - NotAvailableFeature(modifier = modifier) +fun SearchScreen( + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + searchScreenViewModel: SearchScreenViewModel = viewModel() +) { + 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: (Category) -> Unit, + onCategoryUnselected: (Category) -> 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: (Category) -> Unit, + onCategoryUnselected: (Category) -> Unit, + onPodcastSelected: (PodcastWithExtraInfo) -> 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: (Category) -> Unit, + onCategoryUnselected: (Category) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, + toRequestFocus: Boolean = false +) { + LaunchedEffect(toRequestFocus) { + if (toRequestFocus) { + focusRequester.requestFocus() + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.itemGap), + modifier = modifier + ) { + KeywordInput( + keyword = keyword, + onKeywordInput = onKeywordInput, + ) + CategorySelection( + categorySelectionList = categorySelectionList, + onCategorySelected = onCategorySelected, + onCategoryUnselected = onCategoryUnselected, + modifier = Modifier + .focusRestorer() + .focusRequester(focusRequester) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class) +@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) + BasicTextField2( + value = keyword, + onValueChange = onKeywordInput, + textStyle = textStyle, + cursorBrush = cursorBrush, + modifier = modifier, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + decorator = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(percent = 50) + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + 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: (Category) -> Unit, + onCategoryUnselected: (Category) -> Unit, + modifier: Modifier = Modifier +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.chipGap), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.chipGap), + ) { + categorySelectionList.forEach { + FilterChip( + selected = it.isSelected, + onClick = { + if (it.isSelected) { + onCategoryUnselected(it.category) + } else { + onCategorySelected(it.category) + } + } + ) { + Text(text = it.category.name) + } + } + } +} + +@Composable +private fun SearchResult( + podcastList: PodcastList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + header: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + TvLazyVerticalGrid( + columns = TvGridCells.Fixed(4), + horizontalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + modifier = modifier, + ) { + item(span = { TvGridItemSpan(maxLineSpan) }) { + header() + } + items(podcastList) { + PodcastCard(podcast = it.podcast, onClick = { onPodcastSelected(it) }) + } + } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt new file mode 100644 index 0000000000..1f222d5b0f --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt @@ -0,0 +1,132 @@ +/* + * 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.database.model.Category +import com.example.jetcaster.core.data.di.Graph +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.tv.model.CategoryList +import com.example.jetcaster.tv.model.CategorySelection +import com.example.jetcaster.tv.model.CategorySelectionList +import com.example.jetcaster.tv.model.PodcastList +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 + +class SearchScreenViewModel( + private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, + private val podcastStore: PodcastStore = Graph.podcastStore, + categoryStore: CategoryStore = Graph.categoryStore, +) : ViewModel() { + + private val keywordFlow = MutableStateFlow("") + private val selectedCategoryListFlow = MutableStateFlow>(emptyList()) + + private val categoryListFlow = categoryStore.categoriesSortedByPodcastCount().map { + CategoryList(it) + } + + private val searchConditionFlow = + combine(keywordFlow, selectedCategoryListFlow) { keyword, selectedCategories -> + SearchCondition(keyword, selectedCategories) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val searchResultFlow = searchConditionFlow.flatMapLatest { + podcastStore.searchPodcastByTitleAndCategories(it.keyword, it.selectedCategories) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + emptyList() + ) + + private val categorySelectionFlow = + combine(categoryListFlow, 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 = PodcastList(result) + 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: Category) { + val list = selectedCategoryListFlow.value + if (!list.contains(category)) { + selectedCategoryListFlow.value = list + listOf(category) + } + } + + fun removeCategoryFromSelectedCategoryList(category: Category) { + 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: List) + +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 +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt index ef5409312a..538d191885 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -59,4 +59,6 @@ internal data class PaddingSettings( internal data class GapSettings( val catalogItemGap: Dp = 20.dp, val catalogSectionGap: Dp = 40.dp, + val itemGap: Dp = 16.dp, + val chipGap: Dp = 8.dp ) diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 0378e4d1ed..453c63048d 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -38,6 +38,7 @@ Latest Episodes Discover the podcasts Back to Home + Search podcasts by keyword Updated a while ago From 240bec814264463ca68b55e7a23241c10ebfd011 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Fri, 29 Mar 2024 08:50:56 +0900 Subject: [PATCH 049/143] Implement newly introduced methods --- .../core/data/repository/TestPodcastStore.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt index 226ad0ab58..85c9a58d2d 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt @@ -16,6 +16,7 @@ package com.example.jetcaster.core.data.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 kotlinx.coroutines.flow.Flow @@ -55,6 +56,37 @@ class TestPodcastStore : PodcastStore { } } + override fun searchPodcastByTitle( + keyword: String, + limit: Int + ): Flow> = + podcastFlow.map { podcastList -> + podcastList.filter { + it.title.contains(keyword) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List, + limit: Int + ): Flow> = + 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) { followedPodcasts.remove(podcastUri) From d5b2862c09a94f618e6f1d86bfb0eaad045df102 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Fri, 29 Mar 2024 08:56:57 +0900 Subject: [PATCH 050/143] Update the code format --- .../example/jetcaster/core/data/repository/TestPodcastStore.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt index 85c9a58d2d..8d87db3086 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt @@ -86,7 +86,7 @@ class TestPodcastStore : PodcastStore { } } } - + override suspend fun togglePodcastFollowed(podcastUri: String) { if (podcastUri in followedPodcasts) { followedPodcasts.remove(podcastUri) From dc9acb9f34cd2be3c892d00ee96091a58cad4ed8 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 26 Mar 2024 10:57:12 -0700 Subject: [PATCH 051/143] [Jetcaster]: Podcast screen. --- .../com/example/jetcaster/ui/JetcasterApp.kt | 57 ++- .../example/jetcaster/ui/JetcasterAppState.kt | 20 +- .../com/example/jetcaster/ui/home/Home.kt | 40 ++- .../jetcaster/ui/home/HomeViewModel.kt | 6 +- .../ui/home/category/PodcastCategory.kt | 227 ++---------- .../jetcaster/ui/home/discover/Discover.kt | 8 +- .../jetcaster/ui/home/library/Library.kt | 10 +- .../jetcaster/ui/player/PlayerViewModel.kt | 6 +- .../ui/podcast/PodcastDetailsScreen.kt | 328 ++++++++++++++++++ .../ui/podcast/PodcastDetailsViewModel.kt | 98 ++++++ .../jetcaster/ui/shared/EpisodeListItem.kt | 247 +++++++++++++ Jetcaster/app/src/main/res/values/strings.xml | 6 +- .../core/data/database/dao/PodcastsDao.kt | 17 + .../jetcaster/core/data/model/EpisodeInfo.kt | 29 ++ .../core/data/model/PlayerEpisode.kt | 15 +- .../jetcaster/core/data/model/PodcastInfo.kt | 23 ++ .../core/data/repository/PodcastStore.kt | 17 +- .../core/data/repository/TestPodcastStore.kt | 16 +- .../jetcaster/designsystem/theme/Keylines.kt | 2 +- .../jetcaster/designsystem/theme/Shape.kt | 2 +- 20 files changed, 931 insertions(+), 243 deletions(-) create mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt create mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt create mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 93a8d90115..357bbc30b9 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -16,6 +16,13 @@ package com.example.jetcaster.ui +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.EaseIn +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -27,9 +34,14 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.window.layout.DisplayFeature import com.example.jetcaster.R +import com.example.jetcaster.core.data.di.Graph.episodePlayer +import com.example.jetcaster.core.data.di.Graph.episodeStore +import com.example.jetcaster.core.data.di.Graph.podcastStore import com.example.jetcaster.ui.home.Home import com.example.jetcaster.ui.player.PlayerScreen import com.example.jetcaster.ui.player.PlayerViewModel +import com.example.jetcaster.ui.podcast.PodcastDetailsScreen +import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel @Composable fun JetcasterApp( @@ -46,7 +58,10 @@ fun JetcasterApp( Home( navigateToPlayer = { episodeUri -> appState.navigateToPlayer(episodeUri, backStackEntry) - } + }, + navigateToPodcastDetails = { podcastUri -> + appState.navigateToPodcastDetails(podcastUri, backStackEntry) + }, ) } composable(Screen.Player.route) { backStackEntry -> @@ -63,6 +78,46 @@ fun JetcasterApp( onBackPress = appState::navigateBack ) } + composable( + route = Screen.PodcastDetails.route, + enterTransition = { + fadeIn( + animationSpec = tween( + 300, easing = LinearEasing + ) + ) + slideIntoContainer( + animationSpec = tween(300, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) + }, + exitTransition = { + fadeOut( + animationSpec = tween( + 300, easing = LinearEasing + ) + ) + slideOutOfContainer( + animationSpec = tween(300, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) + } + ) { backStackEntry -> + val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( + factory = PodcastDetailsViewModel.provideFactory( + episodeStore = episodeStore, + podcastStore = podcastStore, + episodePlayer = episodePlayer, + owner = backStackEntry, + defaultArgs = backStackEntry.arguments + ) + ) + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = { episodePlayer -> + appState.navigateToPlayer(episodePlayer.uri, backStackEntry) + }, + navigateBack = appState::navigateBack + ) + } } } else { OfflineDialog { appState.refreshOnline() } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt index cc6faa89af..ee938066a7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt @@ -38,9 +38,20 @@ import androidx.navigation.compose.rememberNavController */ sealed class Screen(val route: String) { object Home : Screen("home") - object Player : Screen("player/{episodeUri}") { + object Player : Screen("player/{$ARG_EPISODE_URI}") { fun createRoute(episodeUri: String) = "player/$episodeUri" } + + object PodcastDetails : Screen("podcast/{$ARG_PODCAST_URI}") { + + val PODCAST_URI = "podcastUri" + fun createRoute(podcastUri: String) = "podcast/$podcastUri" + } + + companion object { + val ARG_PODCAST_URI = "podcastUri" + val ARG_EPISODE_URI = "episodeUri" + } } @Composable @@ -70,6 +81,13 @@ class JetcasterAppState( } } + fun navigateToPodcastDetails(podcastUri: String, from: NavBackStackEntry) { + if (from.lifecycleIsResumed()) { + val encodedUri = Uri.encode(podcastUri) + navController.navigate(Screen.PodcastDetails.createRoute(encodedUri)) + } + } + fun navigateBack() { navController.popBackStack() } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 7ca57257c4..4b69d0ea31 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -88,6 +88,7 @@ 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.model.FilterableCategoriesModel +import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.home.library.libraryItems @@ -95,15 +96,16 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime -import kotlinx.collections.immutable.PersistentList -import kotlinx.coroutines.launch @Composable fun Home( navigateToPlayer: (String) -> Unit, + navigateToPodcastDetails: (String) -> Unit, viewModel: HomeViewModel = viewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() @@ -120,9 +122,10 @@ fun Home( onCategorySelected = viewModel::onCategorySelected, onPodcastUnfollowed = viewModel::onPodcastUnfollowed, navigateToPlayer = navigateToPlayer, + navigateToPodcastDetails = navigateToPodcastDetails, onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, - onQueuePodcast = viewModel::onQueuePodcast, + onQueueEpisode = viewModel::onQueueEpisode, modifier = Modifier.fillMaxSize() ) } @@ -186,9 +189,10 @@ fun Home( onHomeCategorySelected: (HomeCategory) -> Unit, onCategorySelected: (Category) -> Unit, navigateToPlayer: (String) -> Unit, + navigateToPodcastDetails: (String) -> Unit, onTogglePodcastFollowed: (String) -> Unit, onLibraryPodcastSelected: (Podcast?) -> Unit, - onQueuePodcast: (EpisodeToPodcast) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, ) { // Effect that changes the home category selection when there are no subscribed podcasts LaunchedEffect(key1 = featuredPodcasts) { @@ -230,13 +234,14 @@ fun Home( onHomeCategorySelected = onHomeCategorySelected, onCategorySelected = onCategorySelected, navigateToPlayer = navigateToPlayer, + navigateToPodcastDetails = navigateToPodcastDetails, onTogglePodcastFollowed = onTogglePodcastFollowed, onLibraryPodcastSelected = onLibraryPodcastSelected, - onQueuePodcast = { + onQueueEpisode = { coroutineScope.launch { snackbarHostState.showSnackbar(snackBarText) } - onQueuePodcast(it) + onQueueEpisode(it) } ) } @@ -258,9 +263,10 @@ private fun HomeContent( onHomeCategorySelected: (HomeCategory) -> Unit, onCategorySelected: (Category) -> Unit, navigateToPlayer: (String) -> Unit, + navigateToPodcastDetails: (String) -> Unit, onTogglePodcastFollowed: (String) -> Unit, onLibraryPodcastSelected: (Podcast?) -> Unit, - onQueuePodcast: (EpisodeToPodcast) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, ) { val pagerState = rememberPagerState { featuredPodcasts.size } LaunchedEffect(pagerState, featuredPodcasts) { @@ -277,6 +283,7 @@ private fun HomeContent( pagerState = pagerState, items = featuredPodcasts, onPodcastUnfollowed = onPodcastUnfollowed, + navigateToPodcastDetails = navigateToPodcastDetails, modifier = Modifier .fillMaxWidth() .verticalGradientScrim( @@ -307,7 +314,8 @@ private fun HomeContent( libraryItems( episodes = libraryEpisodes, navigateToPlayer = navigateToPlayer, - onQueuePodcast = onQueuePodcast + navigateToPodcastDetails = navigateToPodcastDetails, + onQueueEpisode = onQueueEpisode ) } @@ -316,9 +324,10 @@ private fun HomeContent( filterableCategoriesModel = filterableCategoriesModel, podcastCategoryFilterResult = podcastCategoryFilterResult, navigateToPlayer = navigateToPlayer, + navigateToPodcastDetails = navigateToPodcastDetails, onCategorySelected = onCategorySelected, onTogglePodcastFollowed = onTogglePodcastFollowed, - onQueuePodcast = onQueuePodcast + onQueueEpisode = onQueueEpisode ) } } @@ -330,6 +339,7 @@ private fun FollowedPodcastItem( pagerState: PagerState, items: PersistentList, onPodcastUnfollowed: (String) -> Unit, + navigateToPodcastDetails: (String) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -339,6 +349,7 @@ private fun FollowedPodcastItem( pagerState = pagerState, items = items, onPodcastUnfollowed = onPodcastUnfollowed, + navigateToPodcastDetails = navigateToPodcastDetails, modifier = Modifier.fillMaxWidth() ) @@ -404,10 +415,10 @@ private val FEATURED_PODCAST_IMAGE_HEIGHT_DP = 180.dp fun FollowedPodcasts( pagerState: PagerState, items: PersistentList, - modifier: Modifier = Modifier, onPodcastUnfollowed: (String) -> Unit, + navigateToPodcastDetails: (String) -> Unit, + modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() // 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` @@ -433,9 +444,7 @@ fun FollowedPodcasts( modifier = Modifier .fillMaxSize() .clickable { - coroutineScope.launch { - pagerState.animateScrollToPage(page) - } + navigateToPodcastDetails(podcast.uri) } ) } @@ -527,10 +536,11 @@ fun PreviewHomeContent() { onCategorySelected = {}, onPodcastUnfollowed = {}, navigateToPlayer = {}, + navigateToPodcastDetails = {}, onHomeCategorySelected = {}, onTogglePodcastFollowed = {}, onLibraryPodcastSelected = {}, - onQueuePodcast = {} + onQueueEpisode = {} ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index bc4dc29a0a..b3c2680b7e 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -27,8 +27,8 @@ import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase import com.example.jetcaster.core.data.model.FilterableCategoriesModel +import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.core.data.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository @@ -166,8 +166,8 @@ class HomeViewModel( selectedLibraryPodcast.value = podcast } - fun onQueuePodcast(episodeToPodcast: EpisodeToPodcast) { - episodePlayer.addToQueue(episodeToPodcast.toPlayerEpisode()) + fun onQueueEpisode(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index f37bafd9ee..036abd2502 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -16,9 +16,7 @@ package com.example.jetcaster.ui.home.category -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.PaddingValues @@ -27,72 +25,58 @@ import androidx.compose.foundation.layout.aspectRatio 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.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistAdd -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.rounded.PlayCircleFilled -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.platform.LocalContext -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 androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension.Companion.fillToConstraints -import androidx.constraintlayout.compose.Dimension.Companion.preferredWrapContent import coil.compose.AsyncImage import coil.request.ImageRequest -import com.example.jetcaster.R -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.model.PlayerEpisode +import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts +import com.example.jetcaster.ui.shared.EpisodeListItem import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle fun LazyListScope.podcastCategory( topPodcasts: List, episodes: List, navigateToPlayer: (String) -> Unit, - onQueuePodcast: (EpisodeToPodcast) -> Unit, + navigateToPodcastDetails: (String) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, onTogglePodcastFollowed: (String) -> Unit, ) { item { - CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) + CategoryPodcasts( + topPodcasts = topPodcasts, + navigateToPodcastDetails = navigateToPodcastDetails, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) } items(episodes, key = { it.episode.uri }) { item -> EpisodeListItem( episode = item.episode, podcast = item.podcast, - onClick = navigateToPlayer, - onQueuePodcast = onQueuePodcast, + onClick = navigateToPodcastDetails, + onQueueEpisode = onQueueEpisode, modifier = Modifier.fillParentMaxWidth() ) } @@ -101,189 +85,22 @@ fun LazyListScope.podcastCategory( @Composable private fun CategoryPodcasts( topPodcasts: List, + navigateToPodcastDetails: (String) -> Unit, onTogglePodcastFollowed: (String) -> Unit ) { CategoryPodcastRow( podcasts = topPodcasts, onTogglePodcastFollowed = onTogglePodcastFollowed, + navigateToPodcastDetails = navigateToPodcastDetails, modifier = Modifier.fillMaxWidth() ) } -@Composable -fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - onClick: (String) -> Unit, - onQueuePodcast: (EpisodeToPodcast) -> Unit, - modifier: Modifier = Modifier, - showDivider: Boolean = true, -) { - ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() - - if (showDivider) { - HorizontalDivider( - Modifier.constrainAs(divider) { - top.linkTo(parent.top) - centerHorizontallyTo(parent) - width = fillToConstraints - } - ) - } - - // If we have an image Url, we can show it using Coil - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcast.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.medium) - .constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - }, - ) - - Text( - text = episode.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.constrainAs(episodeTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(parent.top, 16.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) - - val titleImageBarrier = createBottomBarrier(podcastTitle, image) - - Text( - text = podcast.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.constrainAs(podcastTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(episodeTitle.bottom, 6.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) - - 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 = rememberRipple(bounded = false, radius = 24.dp) - ) { /* TODO */ } - .size(48.dp) - .padding(6.dp) - .semantics { role = Role.Button } - .constrainAs(playIcon) { - start.linkTo(parent.start, Keyline1) - top.linkTo(titleImageBarrier, margin = 10.dp) - bottom.linkTo(parent.bottom, 10.dp) - } - ) - - 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.constrainAs(date) { - centerVerticallyTo(playIcon) - linkTo( - start = playIcon.end, - startMargin = 12.dp, - end = addPlaylist.start, - endMargin = 16.dp, - bias = 0f // float this towards the start - ) - width = preferredWrapContent - } - ) - - IconButton( - onClick = { - onQueuePodcast( - EpisodeToPodcast().apply { - this.episode = episode - this._podcasts = listOf(podcast) - } - ) - }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(overflow) { - end.linkTo(parent.end, 8.dp) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - @Composable private fun CategoryPodcastRow( podcasts: List, onTogglePodcastFollowed: (String) -> Unit, + navigateToPodcastDetails: (String) -> Unit, modifier: Modifier = Modifier ) { val lastIndex = podcasts.size - 1 @@ -298,7 +115,9 @@ private fun CategoryPodcastRow( podcastImageUrl = podcast.imageUrl, isFollowed = isFollowed, onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, - modifier = Modifier.width(128.dp) + modifier = Modifier.width(128.dp).clickable { + navigateToPodcastDetails(podcast.uri) + } ) if (index < lastIndex) Spacer(Modifier.width(24.dp)) @@ -356,19 +175,15 @@ private fun TopPodcastRowItem( } } -private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) -} - @Preview @Composable fun PreviewEpisodeListItem() { JetcasterTheme { EpisodeListItem( - episode = PreviewEpisodes[0], - podcast = PreviewPodcasts[0], + episode = PreviewEpisodes[0].asExternalModel(), + podcast = PreviewPodcasts[0].asExternalModel(), onClick = { }, - onQueuePodcast = { }, + onQueueEpisode = { }, modifier = Modifier.fillMaxWidth() ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 3507764068..ff63c7c0a7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -38,8 +38,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.model.FilterableCategoriesModel +import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.category.podcastCategory @@ -48,9 +48,10 @@ fun LazyListScope.discoverItems( filterableCategoriesModel: FilterableCategoriesModel, podcastCategoryFilterResult: PodcastCategoryFilterResult, navigateToPlayer: (String) -> Unit, + navigateToPodcastDetails: (String) -> Unit, onCategorySelected: (Category) -> Unit, onTogglePodcastFollowed: (String) -> Unit, - onQueuePodcast: (EpisodeToPodcast) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, ) { if (filterableCategoriesModel.isEmpty) { // TODO: empty state @@ -73,8 +74,9 @@ fun LazyListScope.discoverItems( topPodcasts = podcastCategoryFilterResult.topPodcasts, episodes = podcastCategoryFilterResult.episodes, navigateToPlayer = navigateToPlayer, + navigateToPodcastDetails = navigateToPodcastDetails, onTogglePodcastFollowed = onTogglePodcastFollowed, - onQueuePodcast = onQueuePodcast, + onQueueEpisode = onQueueEpisode, ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index 1c2716907d..649343e827 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -26,13 +26,15 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.designsystem.theme.Keyline1 -import com.example.jetcaster.ui.home.category.EpisodeListItem +import com.example.jetcaster.ui.shared.EpisodeListItem fun LazyListScope.libraryItems( episodes: List, navigateToPlayer: (String) -> Unit, - onQueuePodcast: (EpisodeToPodcast) -> Unit + navigateToPodcastDetails: (String) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit ) { if (episodes.isEmpty()) { // TODO: Empty state @@ -57,8 +59,8 @@ fun LazyListScope.libraryItems( EpisodeListItem( episode = item.episode, podcast = item.podcast, - onClick = navigateToPlayer, - onQueuePodcast = onQueuePodcast, + onClick = navigateToPodcastDetails, + onQueueEpisode = onQueueEpisode, modifier = Modifier.fillParentMaxWidth(), showDivider = index != 0 ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 2acef025e1..b2a58c26cf 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -32,11 +32,12 @@ import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState -import java.time.Duration +import com.example.jetcaster.ui.Screen import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.time.Duration data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() @@ -55,7 +56,8 @@ class PlayerViewModel( // 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("episodeUri")!!) + private val episodeUri: String = + Uri.decode(savedStateHandle.get(Screen.ARG_EPISODE_URI)!!) var uiState by mutableStateOf(PlayerUiState()) private set diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt new file mode 100644 index 0000000000..8646e0fd9f --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,328 @@ +package com.example.jetcaster.ui.podcast + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +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.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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.jetcaster.R +import com.example.jetcaster.core.data.model.EpisodeInfo +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.home.PreviewEpisodes +import com.example.jetcaster.ui.home.PreviewPodcasts +import com.example.jetcaster.ui.shared.EpisodeListItem +import kotlinx.coroutines.launch + +@Composable +fun PodcastDetailsScreen( + viewModel: PodcastDetailsViewModel, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + val state by viewModel.state.collectAsStateWithLifecycle() + PodcastDetailsScreen( + podcast = state.podcast, + isSubscribed = state.isSubscribed, + episodes = state.episodes, + toggleSubscribe = viewModel::toggleSusbcribe, + onQueueEpisode = viewModel::onQueueEpisode, + navigateToPlayer = navigateToPlayer, + navigateBack = navigateBack, + modifier = modifier, + ) +} + +@Composable +fun PodcastDetailsScreen( + podcast: PodcastInfo, + isSubscribed: Boolean, + episodes: List, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + PodcastDetailsTopAppBar( + navigateBack = navigateBack, + modifier = Modifier.fillMaxWidth() + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { contentPadding -> + PodcastDetailsContent( + podcast = podcast, + isSubscribed = isSubscribed, + episodes = episodes, + toggleSubscribe = toggleSubscribe, + onQueueEpisode = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onQueueEpisode(it) + }, + navigateToPlayer = navigateToPlayer, + modifier = Modifier.padding(contentPadding) + ) + } +} + +@Composable +fun PodcastDetailsContent( + podcast: PodcastInfo, + isSubscribed: Boolean, + episodes: List, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier.fillMaxSize() + ) { + item { + PodcastDetailsHeaderItem( + podcast = podcast, + isSubscribed = isSubscribed, + toggleSubscribe = toggleSubscribe, + modifier = Modifier.fillMaxWidth() + ) + } + items(episodes, key = { it.uri }) { episode -> + EpisodeListItem( + episode = episode, + podcast = podcast, + onClick = { navigateToPlayer(episode) }, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillMaxWidth(), + showPodcastImage = false + ) + } + } +} + +@Composable +fun PodcastDetailsHeaderItem( + podcast: PodcastInfo, + isSubscribed: Boolean, + toggleSubscribe: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(Keyline1) + ) { + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcast.imageUrl) + .crossfade(true) + .build(), + contentDescription = stringResource(id = R.string.cd_podcast_image), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(148.dp) + .clip(MaterialTheme.shapes.large) + ) + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineMedium + ) + PodcastDetailsHeaderItemButtons( + isSubscribed = isSubscribed, + onClick = { + toggleSubscribe(podcast) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + PodcastDetailsDescription( + podcast = podcast, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } +} + +@Composable +fun PodcastDetailsDescription( + podcast: PodcastInfo, + modifier: Modifier +) { + var isExpanded by remember { mutableStateOf(false) } + var showSeeMore by remember { mutableStateOf(false) } + Box(modifier = modifier) { + Text( + text = podcast.description, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + onTextLayout = { result -> + showSeeMore = result.hasVisualOverflow + }, + ) + if (showSeeMore) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .background(MaterialTheme.colorScheme.surface) + ) { + // TODO: Add gradient effect + Text( + text = stringResource(id = R.string.see_more), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(start = 16.dp) + .clickable { + isExpanded = !isExpanded + } + ) + } + } + } +} + +@Composable +fun PodcastDetailsHeaderItemButtons( + isSubscribed: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row(modifier.padding(top = 16.dp)) { + Button( + onClick = onClick + ) { + Icon( + imageVector = if (isSubscribed) + Icons.Default.Check + else + Icons.Default.Add, + contentDescription = if (isSubscribed) + stringResource(id = R.string.unsubscribe) + else + stringResource(id = R.string.subscribe) + ) + Text( + text = if (isSubscribed) + stringResource(id = R.string.unsubscribe) + else + stringResource(id = R.string.subscribe), + modifier = Modifier.padding(start = 8.dp) + ) + } + + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.padding(start = 8.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more) + ) + } + } +} + +@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].asExternalModel(), + isSubscribed = false, + toggleSubscribe = { }, + ) +} + +@Preview +@Composable +fun PodcastDetailsScreenPreview() { + PodcastDetailsScreen( + podcast = PreviewPodcasts[0].asExternalModel(), + isSubscribed = false, + episodes = PreviewEpisodes.map { it.asExternalModel() }, + toggleSubscribe = { }, + onQueueEpisode = { }, + navigateToPlayer = { }, + navigateBack = { } + ) +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt new file mode 100644 index 0000000000..0230028ab4 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -0,0 +1,98 @@ +package com.example.jetcaster.ui.podcast + +import android.net.Uri +import android.os.Bundle +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.savedstate.SavedStateRegistryOwner +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.model.EpisodeInfo +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.ui.Screen +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class PodcastUiState( + val podcast: PodcastInfo = PodcastInfo(), + val isSubscribed: Boolean = false, + val episodes: List = emptyList() +) + +/** + * ViewModel that handles the business logic and screen state of the Podcast details screen. + */ +class PodcastDetailsViewModel( + private val episodeStore: EpisodeStore = Graph.episodeStore, + private val episodePlayer: EpisodePlayer = Graph.episodePlayer, + private val podcastStore: PodcastStore = Graph.podcastStore, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val podcastUri: String = + Uri.decode(savedStateHandle.get(Screen.ARG_PODCAST_URI)!!) + + val state: StateFlow = + combine( + podcastStore.podcastWithExtraInfo(podcastUri), + episodeStore.episodesInPodcast(podcastUri) + ) { podcast, episodeToPodcasts -> + val episodes = episodeToPodcasts.map { it.episode.asExternalModel() } + PodcastUiState( + podcast = podcast.podcast.asExternalModel(), + isSubscribed = podcast.isFollowed, + episodes = episodes, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PodcastUiState() + ) + + fun toggleSusbcribe(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + + fun onQueueEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } + + /** + * Factory for [PodcastDetailsViewModel]. + */ + companion object { + fun provideFactory( + episodeStore: EpisodeStore = Graph.episodeStore, + podcastStore: PodcastStore = Graph.podcastStore, + episodePlayer: EpisodePlayer = Graph.episodePlayer, + owner: SavedStateRegistryOwner, + defaultArgs: Bundle? = null, + ): AbstractSavedStateViewModelFactory = + object : AbstractSavedStateViewModelFactory(owner, defaultArgs) { + @Suppress("UNCHECKED_CAST") + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return PodcastDetailsViewModel( + episodeStore = episodeStore, + episodePlayer = episodePlayer, + podcastStore = podcastStore, + savedStateHandle = handle + ) as T + } + } + } +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt new file mode 100644 index 0000000000..eee6d08144 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -0,0 +1,247 @@ +package com.example.jetcaster.ui.shared + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.MoreVert +import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.platform.LocalContext +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.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.Visibility +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.jetcaster.R +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.model.EpisodeInfo +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.designsystem.theme.Keyline1 +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Deprecated("Use the EpisodeListItem overload that accepts external models instead.") +@Composable +fun EpisodeListItem( + episode: Episode, + podcast: Podcast, + onClick: (String) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean = true, + showPodcastImage: Boolean = true, +) { + EpisodeListItem( + episode = episode.asExternalModel(), + podcast = podcast.asExternalModel(), + onClick = onClick, + onQueueEpisode = onQueueEpisode, + modifier = modifier, + showDivider = showDivider, + showPodcastImage = showPodcastImage + ) +} + +@Composable +fun EpisodeListItem( + episode: EpisodeInfo, + podcast: PodcastInfo, + onClick: (String) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean = true, + showPodcastImage: Boolean = true, +) { + ConstraintLayout( + modifier = modifier.clickable { + onClick(podcast.uri) + } + ) { + val ( + divider, episodeTitle, podcastTitle, image, playIcon, + date, addPlaylist, overflow + ) = createRefs() + + if (showDivider) { + HorizontalDivider( + Modifier.constrainAs(divider) { + top.linkTo(parent.top) + centerHorizontallyTo(parent) + width = Dimension.fillToConstraints + } + ) + } + + // If we have an image Url, we can show it using Coil + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcast.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + .constrainAs(image) { + end.linkTo(parent.end, 16.dp) + top.linkTo(parent.top, 16.dp) + visibility = if (showPodcastImage) Visibility.Visible else Visibility.Gone + }, + ) + + Text( + text = episode.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.constrainAs(episodeTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f + ) + top.linkTo(parent.top, 16.dp) + height = Dimension.preferredWrapContent + width = Dimension.preferredWrapContent + } + ) + + val titleImageBarrier = createBottomBarrier(podcastTitle, image) + + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.constrainAs(podcastTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f + ) + top.linkTo(episodeTitle.bottom, 6.dp) + height = Dimension.preferredWrapContent + width = Dimension.preferredWrapContent + } + ) + + 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 = rememberRipple(bounded = false, radius = 24.dp) + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button } + .constrainAs(playIcon) { + start.linkTo(parent.start, Keyline1) + top.linkTo(titleImageBarrier, margin = 10.dp) + bottom.linkTo(parent.bottom, 10.dp) + } + ) + + 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.constrainAs(date) { + centerVerticallyTo(playIcon) + linkTo( + start = playIcon.end, + startMargin = 12.dp, + end = addPlaylist.start, + endMargin = 16.dp, + bias = 0f // float this towards the start + ) + width = Dimension.preferredWrapContent + } + ) + + IconButton( + onClick = { + onQueueEpisode( + PlayerEpisode( + podcastInfo = podcast, + episodeInfo = episode + ) + ) + }, + modifier = Modifier.constrainAs(addPlaylist) { + end.linkTo(overflow.start) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(overflow) { + end.linkTo(parent.end, 8.dp) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index 919d4dc6a1..c14861f017 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -56,6 +56,10 @@ Skip next Skip previous Unfollow - Episode added to your queue + Episode added to your queue + Podcast image + Subscribe + Unsubscribe + see more diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt index e2a754e657..4d5ce71755 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt @@ -31,6 +31,23 @@ abstract class PodcastsDao : BaseDao { @Query("SELECT * FROM podcasts WHERE uri = :uri") abstract fun podcastWithUri(uri: String): Flow + @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 + @Transaction @Query( """ diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt new file mode 100644 index 0000000000..f450a270c9 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt @@ -0,0 +1,29 @@ +package com.example.jetcaster.core.data.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 title: String = "", + val subTitle: String = "", + val summary: String = "", + val author: String = "", + val published: OffsetDateTime = OffsetDateTime.MIN, + val duration: Duration? = null, +) + +fun Episode.asExternalModel(): EpisodeInfo = + EpisodeInfo( + uri = uri, + title = title, + subTitle = subtitle ?: "", + summary = summary ?: "", + author = author ?: "", + published = published, + duration = duration, + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt index 19ca707b1b..9b997ed303 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -19,6 +19,9 @@ package com.example.jetcaster.core.data.model import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import java.time.Duration +/** + * Episode data with necessary information to be used within a player. + */ data class PlayerEpisode( val title: String = "", val subTitle: String = "", @@ -27,7 +30,17 @@ data class PlayerEpisode( val author: String = "", val summary: String = "", val podcastImageUrl: String = "", -) +) { + constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo): this( + title = episodeInfo.title, + subTitle = episodeInfo.subTitle, + duration = episodeInfo.duration, + podcastName = podcastInfo.title, + author = episodeInfo.author, + summary = episodeInfo.summary, + podcastImageUrl = podcastInfo.imageUrl, + ) +} fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = PlayerEpisode( diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt new file mode 100644 index 0000000000..917b11e938 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt @@ -0,0 +1,23 @@ +package com.example.jetcaster.core.data.model + +import com.example.jetcaster.core.data.database.model.Podcast + +/** + * 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 = "", +) + +fun Podcast.asExternalModel(): PodcastInfo = + PodcastInfo( + uri = this.uri, + title = this.title, + author = this.author ?: "", + imageUrl = this.imageUrl ?: "", + description = this.description ?: "" + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt index 160ce3fc2e..31a660b007 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -28,9 +28,14 @@ import kotlinx.coroutines.flow.Flow interface PodcastStore { /** * Return a flow containing the [Podcast] with the given [uri]. - */ + */ fun podcastWithUri(uri: String): Flow + /** + * Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri]. + */ + fun podcastWithExtraInfo(podcastUri: String): Flow + /** * Returns a flow containing the entire collection of podcasts, sorted by the last episode * publish date for each podcast. @@ -69,6 +74,8 @@ interface PodcastStore { suspend fun togglePodcastFollowed(podcastUri: String) + suspend fun followPodcast(podcastUri: String) + suspend fun unfollowPodcast(podcastUri: String) /** @@ -96,6 +103,12 @@ class LocalPodcastStore( return podcastDao.podcastWithUri(uri) } + /** + * Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri]. + */ + override fun podcastWithExtraInfo(podcastUri: String): Flow = + podcastDao.podcastWithExtraInfo(podcastUri) + /** * Returns a flow containing the entire collection of podcasts, sorted by the last episode * publish date for each podcast. @@ -132,7 +145,7 @@ class LocalPodcastStore( return podcastDao.searchPodcastByTitleAndCategory(keyword, categoryIdList, limit) } - private suspend fun followPodcast(podcastUri: String) { + override suspend fun followPodcast(podcastUri: String) { podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri)) } diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt index 8d87db3086..be95e2951d 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt @@ -34,6 +34,14 @@ class TestPodcastStore : PodcastStore { podcasts.first { it.uri == uri } } + override fun podcastWithExtraInfo(podcastUri: String): Flow = + podcastFlow.map { podcasts -> + val podcast = podcasts.first { it.uri == podcastUri } + PodcastWithExtraInfo().apply { + this.podcast = podcast + } + } + override fun podcastsSortedByLastEpisode(limit: Int): Flow> = podcastFlow.map { podcasts -> podcasts.map { p -> @@ -89,12 +97,16 @@ class TestPodcastStore : PodcastStore { override suspend fun togglePodcastFollowed(podcastUri: String) { if (podcastUri in followedPodcasts) { - followedPodcasts.remove(podcastUri) + unfollowPodcast(podcastUri) } else { - followedPodcasts.add(podcastUri) + followPodcast(podcastUri) } } + override suspend fun followPodcast(podcastUri: String) { + followedPodcasts.add(podcastUri) + } + override suspend fun unfollowPodcast(podcastUri: String) { followedPodcasts.remove(podcastUri) } diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt index e097575da7..4340443cbf 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt @@ -18,4 +18,4 @@ package com.example.jetcaster.designsystem.theme import androidx.compose.ui.unit.dp -val Keyline1 = 24.dp +val Keyline1 = 16.dp diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt index 78e51c6dd6..41bbfefbb6 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt @@ -23,5 +23,5 @@ import androidx.compose.ui.unit.dp val JetcasterShapes = Shapes( small = RoundedCornerShape(percent = 50), medium = RoundedCornerShape(size = 8.dp), - large = RoundedCornerShape(size = 0.dp) + large = RoundedCornerShape(size = 16.dp) ) From 00b045619fd02d97833bce4d6cbabcb2a7ef07ce Mon Sep 17 00:00:00 2001 From: arriolac Date: Tue, 26 Mar 2024 23:54:52 +0000 Subject: [PATCH 052/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/jetcaster/ui/home/Home.kt | 4 ++-- .../jetcaster/ui/player/PlayerViewModel.kt | 2 +- .../ui/podcast/PodcastDetailsScreen.kt | 16 ++++++++++++++++ .../ui/podcast/PodcastDetailsViewModel.kt | 16 ++++++++++++++++ .../jetcaster/ui/shared/EpisodeListItem.kt | 16 ++++++++++++++++ .../jetcaster/core/data/model/EpisodeInfo.kt | 18 +++++++++++++++++- .../jetcaster/core/data/model/PlayerEpisode.kt | 2 +- .../jetcaster/core/data/model/PodcastInfo.kt | 16 ++++++++++++++++ .../core/data/repository/PodcastStore.kt | 2 +- 9 files changed, 86 insertions(+), 6 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 4b69d0ea31..d11f0a3b2c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -96,11 +96,11 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.launch @Composable fun Home( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index b2a58c26cf..ef46376ff7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -33,11 +33,11 @@ import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen +import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.time.Duration data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index 8646e0fd9f..35e51525ae 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -1,3 +1,19 @@ +/* + * 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.background diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 0230028ab4..41ae7d66d8 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index eee6d08144..cf10ddb0f1 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -1,3 +1,19 @@ +/* + * 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.Image diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt index f450a270c9..4f184f7a6c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt @@ -1,3 +1,19 @@ +/* + * 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.model import com.example.jetcaster.core.data.database.model.Episode @@ -21,7 +37,7 @@ fun Episode.asExternalModel(): EpisodeInfo = EpisodeInfo( uri = uri, title = title, - subTitle = subtitle ?: "", + subTitle = subtitle ?: "", summary = summary ?: "", author = author ?: "", published = published, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt index 9b997ed303..109810e3e0 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -31,7 +31,7 @@ data class PlayerEpisode( val summary: String = "", val podcastImageUrl: String = "", ) { - constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo): this( + constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( title = episodeInfo.title, subTitle = episodeInfo.subTitle, duration = episodeInfo.duration, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt index 917b11e938..1df2e26a34 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt @@ -1,3 +1,19 @@ +/* + * 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.model import com.example.jetcaster.core.data.database.model.Podcast diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt index 31a660b007..1241082aa7 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.Flow interface PodcastStore { /** * Return a flow containing the [Podcast] with the given [uri]. - */ + */ fun podcastWithUri(uri: String): Flow /** From 7714e5d47258ff321718fbaa9f542b87d071c26b Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 27 Mar 2024 11:32:31 -0700 Subject: [PATCH 053/143] Refactor screens to use external models. --- .../com/example/jetcaster/ui/JetcasterApp.kt | 15 ++- .../com/example/jetcaster/ui/home/Home.kt | 96 +++++++++---------- .../jetcaster/ui/home/HomeViewModel.kt | 40 ++++---- .../example/jetcaster/ui/home/PreviewData.kt | 3 +- .../ui/home/category/PodcastCategory.kt | 39 ++++---- .../jetcaster/ui/home/discover/Discover.kt | 16 ++-- .../jetcaster/ui/home/library/Library.kt | 19 ++-- .../jetcaster/ui/player/PlayerViewModel.kt | 5 +- .../ui/podcast/PodcastDetailsScreen.kt | 10 +- .../ui/podcast/PodcastDetailsViewModel.kt | 4 +- .../jetcaster/ui/shared/EpisodeListItem.kt | 29 +----- .../domain/FilterableCategoriesUseCase.kt | 12 ++- .../domain/PodcastCategoryFilterUseCase.kt | 9 +- .../jetcaster/core/data/model/CategoryInfo.kt | 14 +++ .../data/model/FilterableCategoriesModel.kt | 6 +- .../jetcaster/core/data/model/LibraryInfo.kt | 6 ++ .../data/model/PodcastCategoryFilterResult.kt | 16 +++- .../jetcaster/core/data/model/PodcastInfo.kt | 12 ++- 18 files changed, 177 insertions(+), 174 deletions(-) create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 357bbc30b9..c639aa6530 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -49,18 +49,15 @@ fun JetcasterApp( displayFeatures: List, appState: JetcasterAppState = rememberJetcasterAppState() ) { - if (appState.isOnline) { + //if (appState.isOnline) { NavHost( navController = appState.navController, startDestination = Screen.Home.route ) { composable(Screen.Home.route) { backStackEntry -> Home( - navigateToPlayer = { episodeUri -> - appState.navigateToPlayer(episodeUri, backStackEntry) - }, - navigateToPodcastDetails = { podcastUri -> - appState.navigateToPodcastDetails(podcastUri, backStackEntry) + navigateToPodcastDetails = { podcast -> + appState.navigateToPodcastDetails(podcast.uri, backStackEntry) }, ) } @@ -119,9 +116,9 @@ fun JetcasterApp( ) } } - } else { - OfflineDialog { appState.refreshOnline() } - } +// } else { +// OfflineDialog { appState.refreshOnline() } +// } } @Composable diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index d11f0a3b2c..be928e292f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -83,29 +83,30 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.jetcaster.R -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.Podcast -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.model.CategoryInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel +import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.home.library.libraryItems import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime -import kotlinx.collections.immutable.PersistentList -import kotlinx.coroutines.launch @Composable fun Home( - navigateToPlayer: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, viewModel: HomeViewModel = viewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() @@ -117,11 +118,10 @@ fun Home( selectedHomeCategory = viewState.selectedHomeCategory, filterableCategoriesModel = viewState.filterableCategoriesModel, podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, - libraryEpisodes = viewState.libraryEpisodes, + library = viewState.library, onHomeCategorySelected = viewModel::onHomeCategorySelected, onCategorySelected = viewModel::onCategorySelected, onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - navigateToPlayer = navigateToPlayer, navigateToPodcastDetails = navigateToPodcastDetails, onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, @@ -177,21 +177,20 @@ fun HomeAppBar( @Composable fun Home( - featuredPodcasts: PersistentList, + featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, homeCategories: List, filterableCategoriesModel: FilterableCategoriesModel, podcastCategoryFilterResult: PodcastCategoryFilterResult, - libraryEpisodes: List, + library: LibraryInfo, modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, + onPodcastUnfollowed: (PodcastInfo) -> Unit, onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, - onLibraryPodcastSelected: (Podcast?) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onLibraryPodcastSelected: (PodcastInfo?) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, ) { // Effect that changes the home category selection when there are no subscribed podcasts @@ -227,13 +226,12 @@ fun Home( homeCategories = homeCategories, filterableCategoriesModel = filterableCategoriesModel, podcastCategoryFilterResult = podcastCategoryFilterResult, - libraryEpisodes = libraryEpisodes, + library = library, scrimColor = scrimColor, modifier = Modifier.padding(contentPadding), onPodcastUnfollowed = onPodcastUnfollowed, onHomeCategorySelected = onHomeCategorySelected, onCategorySelected = onCategorySelected, - navigateToPlayer = navigateToPlayer, navigateToPodcastDetails = navigateToPodcastDetails, onTogglePodcastFollowed = onTogglePodcastFollowed, onLibraryPodcastSelected = onLibraryPodcastSelected, @@ -250,22 +248,21 @@ fun Home( @OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeContent( - featuredPodcasts: PersistentList, + featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, homeCategories: List, filterableCategoriesModel: FilterableCategoriesModel, podcastCategoryFilterResult: PodcastCategoryFilterResult, - libraryEpisodes: List, + library: LibraryInfo, scrimColor: Color, modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, + onPodcastUnfollowed: (PodcastInfo) -> Unit, onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, - onLibraryPodcastSelected: (Podcast?) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onLibraryPodcastSelected: (PodcastInfo?) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, ) { val pagerState = rememberPagerState { featuredPodcasts.size } @@ -273,7 +270,7 @@ private fun HomeContent( snapshotFlow { pagerState.currentPage } .collect { val podcast = featuredPodcasts.getOrNull(pagerState.currentPage) - onLibraryPodcastSelected(podcast?.podcast) + onLibraryPodcastSelected(podcast) } } LazyColumn(modifier = modifier.fillMaxSize()) { @@ -312,8 +309,7 @@ private fun HomeContent( when (selectedHomeCategory) { HomeCategory.Library -> { libraryItems( - episodes = libraryEpisodes, - navigateToPlayer = navigateToPlayer, + library = library, navigateToPodcastDetails = navigateToPodcastDetails, onQueueEpisode = onQueueEpisode ) @@ -323,7 +319,6 @@ private fun HomeContent( discoverItems( filterableCategoriesModel = filterableCategoriesModel, podcastCategoryFilterResult = podcastCategoryFilterResult, - navigateToPlayer = navigateToPlayer, navigateToPodcastDetails = navigateToPodcastDetails, onCategorySelected = onCategorySelected, onTogglePodcastFollowed = onTogglePodcastFollowed, @@ -337,9 +332,9 @@ private fun HomeContent( @Composable private fun FollowedPodcastItem( pagerState: PagerState, - items: PersistentList, - onPodcastUnfollowed: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, + items: PersistentList, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -414,9 +409,9 @@ private val FEATURED_PODCAST_IMAGE_HEIGHT_DP = 180.dp @Composable fun FollowedPodcasts( pagerState: PagerState, - items: PersistentList, - onPodcastUnfollowed: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, + items: PersistentList, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, modifier: Modifier = Modifier, ) { // TODO: Using BoxWithConstraints is not quite performant since it requires 2 passes to compute @@ -435,16 +430,16 @@ fun FollowedPodcasts( pageSpacing = 24.dp, pageSize = PageSize.Fixed(FEATURED_PODCAST_IMAGE_WIDTH_DP) ) { page -> - val (podcast, lastEpisodeDate) = items[page] + val podcast = items[page] FollowedPodcastCarouselItem( podcastImageUrl = podcast.imageUrl, podcastTitle = podcast.title, - onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, - lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, + onUnfollowedClick = { onPodcastUnfollowed(podcast) }, + lastEpisodeDateText = podcast.lastEpisodeDate?.let { lastUpdated(it) }, modifier = Modifier .fillMaxSize() .clickable { - navigateToPodcastDetails(podcast.uri) + navigateToPodcastDetails(podcast) } ) } @@ -520,22 +515,25 @@ private fun lastUpdated(updated: OffsetDateTime): String { fun PreviewHomeContent() { JetcasterTheme { Home( - featuredPodcasts = PreviewPodcastsWithExtraInfo, + featuredPodcasts = PreviewPodcastsWithExtraInfo.map { + it.asExternalModel() + }.toPersistentList(), isRefreshing = false, homeCategories = HomeCategory.entries, selectedHomeCategory = HomeCategory.Discover, filterableCategoriesModel = FilterableCategoriesModel( - categories = PreviewCategories, - selectedCategory = PreviewCategories.firstOrNull() + categories = PreviewCategories.map { it.asExternalModel() }, + selectedCategory = PreviewCategories.firstOrNull()?.asExternalModel() ), podcastCategoryFilterResult = PodcastCategoryFilterResult( - topPodcasts = PreviewPodcastsWithExtraInfo, - episodes = PreviewEpisodeToPodcasts, + topPodcasts = PreviewPodcastsWithExtraInfo.map { it.asExternalModel() }, + episodes = PreviewEpisodeToPodcasts.map { + it.asPodcastCategoryEpisode() + } ), - libraryEpisodes = emptyList(), + library = LibraryInfo(), onCategorySelected = {}, onPodcastUnfollowed = {}, - navigateToPlayer = {}, navigateToPodcastDetails = {}, onHomeCategorySelected = {}, onTogglePodcastFollowed = {}, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index b3c2680b7e..36a53cdaa0 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -18,17 +18,17 @@ package com.example.jetcaster.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -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.Podcast -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase -import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase +import com.example.jetcaster.core.data.model.CategoryInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel +import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository @@ -49,8 +49,6 @@ class HomeViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, private val podcastStore: PodcastStore = Graph.podcastStore, private val episodeStore: EpisodeStore = Graph.episodeStore, - private val getLatestFollowedEpisodesUseCase: GetLatestFollowedEpisodesUseCase = - Graph.getLatestFollowedEpisodesUseCase, private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase = Graph.podcastCategoryFilterUseCase, private val filterableCategoriesUseCase: FilterableCategoriesUseCase = @@ -58,13 +56,13 @@ class HomeViewModel( private val episodePlayer: EpisodePlayer = Graph.episodePlayer ) : ViewModel() { // Holds our currently selected podcast in the library - private val selectedLibraryPodcast = MutableStateFlow(null) + private val selectedLibraryPodcast = MutableStateFlow(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(null) + private val _selectedCategory = MutableStateFlow(null) // Holds our view state which the UI collects via [state] private val _state = MutableStateFlow(HomeViewState()) // Holds the view state if the UI is refreshing for new data @@ -112,11 +110,11 @@ class HomeViewModel( HomeViewState( homeCategories = homeCategories, selectedHomeCategory = homeCategory, - featuredPodcasts = podcasts.toPersistentList(), + featuredPodcasts = podcasts.map { it.asExternalModel() }.toPersistentList(), refreshing = refreshing, filterableCategoriesModel = filterableCategories, podcastCategoryFilterResult = podcastCategoryFilterResult, - libraryEpisodes = libraryEpisodes, + library = libraryEpisodes.asLibrary(), errorMessage = null, /* TODO */ ) }.catch { throwable -> @@ -142,7 +140,7 @@ class HomeViewModel( } } - fun onCategorySelected(category: Category) { + fun onCategorySelected(category: CategoryInfo) { _selectedCategory.value = category } @@ -150,19 +148,19 @@ class HomeViewModel( selectedHomeCategory.value = category } - fun onPodcastUnfollowed(podcastUri: String) { + fun onPodcastUnfollowed(podcast: PodcastInfo) { viewModelScope.launch { - podcastStore.unfollowPodcast(podcastUri) + podcastStore.unfollowPodcast(podcast.uri) } } - fun onTogglePodcastFollowed(podcastUri: String) { + fun onTogglePodcastFollowed(podcast: PodcastInfo) { viewModelScope.launch { - podcastStore.togglePodcastFollowed(podcastUri) + podcastStore.togglePodcastFollowed(podcast.uri) } } - fun onLibraryPodcastSelected(podcast: Podcast?) { + fun onLibraryPodcastSelected(podcast: PodcastInfo?) { selectedLibraryPodcast.value = podcast } @@ -171,17 +169,23 @@ class HomeViewModel( } } +private fun List.asLibrary(): LibraryInfo = + LibraryInfo( + podcast = this.firstOrNull()?.podcast?.asExternalModel(), + episodes = this.map { it.episode.asExternalModel() } + ) + enum class HomeCategory { Library, Discover } data class HomeViewState( - val featuredPodcasts: PersistentList = persistentListOf(), + val featuredPodcasts: PersistentList = persistentListOf(), val refreshing: Boolean = false, val selectedHomeCategory: HomeCategory = HomeCategory.Discover, val homeCategories: List = emptyList(), val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), - val libraryEpisodes: List = emptyList(), + val library: LibraryInfo = LibraryInfo(), val errorMessage: String? = null ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index a0968fd164..8705e62736 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -23,7 +23,6 @@ import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import java.time.OffsetDateTime import java.time.ZoneOffset -import kotlinx.collections.immutable.toPersistentList val PreviewCategories = listOf( Category(name = "Crime"), @@ -50,7 +49,7 @@ val PreviewPodcastsWithExtraInfo = PreviewPodcasts.mapIndexed { index, podcast - this.lastEpisodeDate = OffsetDateTime.now() this.isFollowed = index % 2 == 0 } -}.toPersistentList() +} val PreviewEpisodes = listOf( Episode( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index 036abd2502..aadcb4a492 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -44,9 +44,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.data.model.PodcastInfo import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes @@ -56,21 +56,20 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton fun LazyListScope.podcastCategory( - topPodcasts: List, - episodes: List, - navigateToPlayer: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, ) { item { CategoryPodcasts( - topPodcasts = topPodcasts, + topPodcasts = podcastCategoryFilterResult.topPodcasts, navigateToPodcastDetails = navigateToPodcastDetails, onTogglePodcastFollowed = onTogglePodcastFollowed ) } + val episodes = podcastCategoryFilterResult.episodes items(episodes, key = { it.episode.uri }) { item -> EpisodeListItem( episode = item.episode, @@ -84,9 +83,9 @@ fun LazyListScope.podcastCategory( @Composable private fun CategoryPodcasts( - topPodcasts: List, - navigateToPodcastDetails: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit + topPodcasts: List, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit ) { CategoryPodcastRow( podcasts = topPodcasts, @@ -98,9 +97,9 @@ private fun CategoryPodcasts( @Composable private fun CategoryPodcastRow( - podcasts: List, - onTogglePodcastFollowed: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, + podcasts: List, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, modifier: Modifier = Modifier ) { val lastIndex = podcasts.size - 1 @@ -108,15 +107,17 @@ private fun CategoryPodcastRow( modifier = modifier, contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) ) { - itemsIndexed(items = podcasts) { index: Int, - (podcast, _, isFollowed): PodcastWithExtraInfo -> + itemsIndexed( + items = podcasts, + key = { _, p -> p.uri } + ) { index, podcast -> TopPodcastRowItem( podcastTitle = podcast.title, podcastImageUrl = podcast.imageUrl, - isFollowed = isFollowed, - onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, + isFollowed = podcast.isSubscribed ?: false, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast) }, modifier = Modifier.width(128.dp).clickable { - navigateToPodcastDetails(podcast.uri) + navigateToPodcastDetails(podcast) } ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index ff63c7c0a7..78113f6e72 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -37,20 +37,20 @@ 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.data.database.model.Category +import com.example.jetcaster.core.data.model.CategoryInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.data.model.PodcastInfo import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.category.podcastCategory fun LazyListScope.discoverItems( filterableCategoriesModel: FilterableCategoriesModel, podcastCategoryFilterResult: PodcastCategoryFilterResult, - navigateToPlayer: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, - onCategorySelected: (Category) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, ) { if (filterableCategoriesModel.isEmpty) { @@ -71,9 +71,7 @@ fun LazyListScope.discoverItems( } podcastCategory( - topPodcasts = podcastCategoryFilterResult.topPodcasts, - episodes = podcastCategoryFilterResult.episodes, - navigateToPlayer = navigateToPlayer, + podcastCategoryFilterResult = podcastCategoryFilterResult, navigateToPodcastDetails = navigateToPodcastDetails, onTogglePodcastFollowed = onTogglePodcastFollowed, onQueueEpisode = onQueueEpisode, @@ -85,7 +83,7 @@ private val emptyTabIndicator: @Composable (List) -> Unit = {} @Composable private fun PodcastCategoryTabs( filterableCategoriesModel: FilterableCategoriesModel, - onCategorySelected: (Category) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, modifier: Modifier = Modifier ) { val selectedIndex = filterableCategoriesModel.categories.indexOf( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index 649343e827..91a58499c4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -25,18 +25,19 @@ 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.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.PodcastInfo import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.shared.EpisodeListItem fun LazyListScope.libraryItems( - episodes: List, - navigateToPlayer: (String) -> Unit, - navigateToPodcastDetails: (String) -> Unit, + library: LibraryInfo, + navigateToPodcastDetails: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit ) { - if (episodes.isEmpty()) { + val podcast = library.podcast + if (podcast == null || library.episodes.isEmpty()) { // TODO: Empty state return } @@ -53,12 +54,12 @@ fun LazyListScope.libraryItems( } itemsIndexed( - episodes, - key = { _, item -> item.episode.uri } + library.episodes, + key = { _, item -> item.uri } ) { index, item -> EpisodeListItem( - episode = item.episode, - podcast = item.podcast, + episode = item, + podcast = podcast, onClick = navigateToPodcastDetails, onQueueEpisode = onQueueEpisode, modifier = Modifier.fillParentMaxWidth(), diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index ef46376ff7..64ec980f68 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -29,7 +29,6 @@ import androidx.savedstate.SavedStateRegistryOwner import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore -import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen @@ -49,7 +48,6 @@ data class PlayerUiState( @OptIn(ExperimentalCoroutinesApi::class) class PlayerViewModel( episodeStore: EpisodeStore = Graph.episodeStore, - podcastStore: PodcastStore = Graph.podcastStore, private val episodePlayer: EpisodePlayer = Graph.episodePlayer, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -110,7 +108,6 @@ class PlayerViewModel( companion object { fun provideFactory( episodeStore: EpisodeStore = Graph.episodeStore, - podcastStore: PodcastStore = Graph.podcastStore, episodePlayer: EpisodePlayer = Graph.episodePlayer, owner: SavedStateRegistryOwner, defaultArgs: Bundle? = null, @@ -122,7 +119,7 @@ class PlayerViewModel( modelClass: Class, handle: SavedStateHandle ): T { - return PlayerViewModel(episodeStore, podcastStore, episodePlayer, handle) as T + return PlayerViewModel(episodeStore, episodePlayer, handle) as T } } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index 35e51525ae..e9e18a2cf3 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -81,7 +81,6 @@ fun PodcastDetailsScreen( val state by viewModel.state.collectAsStateWithLifecycle() PodcastDetailsScreen( podcast = state.podcast, - isSubscribed = state.isSubscribed, episodes = state.episodes, toggleSubscribe = viewModel::toggleSusbcribe, onQueueEpisode = viewModel::onQueueEpisode, @@ -94,7 +93,6 @@ fun PodcastDetailsScreen( @Composable fun PodcastDetailsScreen( podcast: PodcastInfo, - isSubscribed: Boolean, episodes: List, toggleSubscribe: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, @@ -119,7 +117,6 @@ fun PodcastDetailsScreen( ) { contentPadding -> PodcastDetailsContent( podcast = podcast, - isSubscribed = isSubscribed, episodes = episodes, toggleSubscribe = toggleSubscribe, onQueueEpisode = { @@ -137,7 +134,6 @@ fun PodcastDetailsScreen( @Composable fun PodcastDetailsContent( podcast: PodcastInfo, - isSubscribed: Boolean, episodes: List, toggleSubscribe: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, @@ -150,7 +146,6 @@ fun PodcastDetailsContent( item { PodcastDetailsHeaderItem( podcast = podcast, - isSubscribed = isSubscribed, toggleSubscribe = toggleSubscribe, modifier = Modifier.fillMaxWidth() ) @@ -171,7 +166,6 @@ fun PodcastDetailsContent( @Composable fun PodcastDetailsHeaderItem( podcast: PodcastInfo, - isSubscribed: Boolean, toggleSubscribe: (PodcastInfo) -> Unit, modifier: Modifier = Modifier ) { @@ -203,7 +197,7 @@ fun PodcastDetailsHeaderItem( style = MaterialTheme.typography.headlineMedium ) PodcastDetailsHeaderItemButtons( - isSubscribed = isSubscribed, + isSubscribed = podcast.isSubscribed ?: false, onClick = { toggleSubscribe(podcast) }, @@ -324,7 +318,6 @@ fun PodcastDetailsTopAppBar( fun PodcastDetailsHeaderItemPreview() { PodcastDetailsHeaderItem( podcast = PreviewPodcasts[0].asExternalModel(), - isSubscribed = false, toggleSubscribe = { }, ) } @@ -334,7 +327,6 @@ fun PodcastDetailsHeaderItemPreview() { fun PodcastDetailsScreenPreview() { PodcastDetailsScreen( podcast = PreviewPodcasts[0].asExternalModel(), - isSubscribed = false, episodes = PreviewEpisodes.map { it.asExternalModel() }, toggleSubscribe = { }, onQueueEpisode = { }, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 41ae7d66d8..a06c519c76 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -40,7 +40,6 @@ import kotlinx.coroutines.launch data class PodcastUiState( val podcast: PodcastInfo = PodcastInfo(), - val isSubscribed: Boolean = false, val episodes: List = emptyList() ) @@ -64,8 +63,7 @@ class PodcastDetailsViewModel( ) { podcast, episodeToPodcasts -> val episodes = episodeToPodcasts.map { it.episode.asExternalModel() } PodcastUiState( - podcast = podcast.podcast.asExternalModel(), - isSubscribed = podcast.isFollowed, + podcast = podcast.podcast.asExternalModel().copy(isSubscribed = podcast.isFollowed), episodes = episodes, ) }.stateIn( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index cf10ddb0f1..4a51977cc5 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -50,43 +50,18 @@ import androidx.constraintlayout.compose.Visibility import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R -import com.example.jetcaster.core.data.database.model.Episode -import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.core.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.designsystem.theme.Keyline1 import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -@Deprecated("Use the EpisodeListItem overload that accepts external models instead.") -@Composable -fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - onClick: (String) -> Unit, - onQueueEpisode: (PlayerEpisode) -> Unit, - modifier: Modifier = Modifier, - showDivider: Boolean = true, - showPodcastImage: Boolean = true, -) { - EpisodeListItem( - episode = episode.asExternalModel(), - podcast = podcast.asExternalModel(), - onClick = onClick, - onQueueEpisode = onQueueEpisode, - modifier = modifier, - showDivider = showDivider, - showPodcastImage = showPodcastImage - ) -} - @Composable fun EpisodeListItem( episode: EpisodeInfo, podcast: PodcastInfo, - onClick: (String) -> Unit, + onClick: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, showDivider: Boolean = true, @@ -94,7 +69,7 @@ fun EpisodeListItem( ) { ConstraintLayout( modifier = modifier.clickable { - onClick(podcast.uri) + onClick(podcast) } ) { val ( diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt index f6b2632d09..357258b8ba 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt @@ -16,8 +16,9 @@ package com.example.jetcaster.core.data.domain -import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.model.CategoryInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel +import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.repository.CategoryStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -34,12 +35,13 @@ class FilterableCategoriesUseCase( * returned by the backing category list will be selected in the returned * FilterableCategoriesModel */ - operator fun invoke(selectedCategory: Category?): Flow = + operator fun invoke(selectedCategory: CategoryInfo?): Flow = categoryStore.categoriesSortedByPodcastCount() - .map { + .map { categories -> FilterableCategoriesModel( - categories = it, - selectedCategory = selectedCategory ?: it.firstOrNull() + categories = categories.map { it.asExternalModel() }, + selectedCategory = selectedCategory ?: + categories.firstOrNull()?.asExternalModel() ) } } diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt index 981c0fd3db..de978cc18d 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt @@ -17,7 +17,10 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.model.CategoryInfo import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.CategoryStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -29,7 +32,7 @@ import kotlinx.coroutines.flow.flowOf class PodcastCategoryFilterUseCase( private val categoryStore: CategoryStore ) { - operator fun invoke(category: Category?): Flow { + operator fun invoke(category: CategoryInfo?): Flow { if (category == null) { return flowOf(PodcastCategoryFilterResult()) } @@ -47,8 +50,8 @@ class PodcastCategoryFilterUseCase( // Combine our flows and collect them into the view state StateFlow return combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes -> PodcastCategoryFilterResult( - topPodcasts = topPodcasts, - episodes = episodes + topPodcasts = topPodcasts.map { it.asExternalModel() }, + episodes = episodes.map { it.asPodcastCategoryEpisode() } ) } } diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt new file mode 100644 index 0000000000..b92e11d5c3 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt @@ -0,0 +1,14 @@ +package com.example.jetcaster.core.data.model + +import com.example.jetcaster.core.data.database.model.Category + +data class CategoryInfo( + val id: Long, + val name: String +) + +fun Category.asExternalModel() = + CategoryInfo( + id = id, + name = name + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/FilterableCategoriesModel.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/FilterableCategoriesModel.kt index 16a3b8fe0c..ca02e7fb56 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/FilterableCategoriesModel.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/FilterableCategoriesModel.kt @@ -16,14 +16,12 @@ package com.example.jetcaster.core.data.model -import com.example.jetcaster.core.data.database.model.Category - /** * Model holding a list of categories and a selected category in the collection */ data class FilterableCategoriesModel( - val categories: List = emptyList(), - val selectedCategory: Category? = null + val categories: List = emptyList(), + val selectedCategory: CategoryInfo? = null ) { val isEmpty = categories.isEmpty() || selectedCategory == null } diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt new file mode 100644 index 0000000000..2650a43546 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt @@ -0,0 +1,6 @@ +package com.example.jetcaster.core.data.model + +data class LibraryInfo( + val podcast: PodcastInfo? = null, + val episodes: List = emptyList() +) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastCategoryFilterResult.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastCategoryFilterResult.kt index 4a0e4fa4cf..dbc39daddf 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastCategoryFilterResult.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastCategoryFilterResult.kt @@ -17,12 +17,22 @@ package com.example.jetcaster.core.data.model import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo /** * A model holding top podcasts and matching episodes when filtering based on a category. */ data class PodcastCategoryFilterResult( - val topPodcasts: List = emptyList(), - val episodes: List = emptyList() + val topPodcasts: List = emptyList(), + val episodes: List = emptyList() ) + +data class PodcastCategoryEpisode( + val episode: EpisodeInfo, + val podcast: PodcastInfo, +) + +fun EpisodeToPodcast.asPodcastCategoryEpisode(): PodcastCategoryEpisode = + PodcastCategoryEpisode( + episode = episode.asExternalModel(), + podcast = podcast.asExternalModel(), + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt index 1df2e26a34..147a840944 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt @@ -17,6 +17,8 @@ package com.example.jetcaster.core.data.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. @@ -27,6 +29,8 @@ data class PodcastInfo( val author: String = "", val imageUrl: String = "", val description: String = "", + val isSubscribed: Boolean? = null, + val lastEpisodeDate: OffsetDateTime? = null, ) fun Podcast.asExternalModel(): PodcastInfo = @@ -35,5 +39,11 @@ fun Podcast.asExternalModel(): PodcastInfo = title = this.title, author = this.author ?: "", imageUrl = this.imageUrl ?: "", - description = this.description ?: "" + description = this.description ?: "", + ) + +fun PodcastWithExtraInfo.asExternalModel(): PodcastInfo = + this.podcast.asExternalModel().copy( + isSubscribed = isFollowed, + lastEpisodeDate = lastEpisodeDate, ) From 3f2c2de52f6f526b302313f22e455d6890dd1cb6 Mon Sep 17 00:00:00 2001 From: arriolac Date: Thu, 28 Mar 2024 13:37:47 +0000 Subject: [PATCH 054/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/jetcaster/ui/JetcasterApp.kt | 120 +++++++++--------- .../com/example/jetcaster/ui/home/Home.kt | 6 +- .../domain/FilterableCategoriesUseCase.kt | 4 +- .../jetcaster/core/data/model/CategoryInfo.kt | 16 +++ .../jetcaster/core/data/model/LibraryInfo.kt | 16 +++ 5 files changed, 97 insertions(+), 65 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index c639aa6530..f778a2033e 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -49,73 +49,73 @@ fun JetcasterApp( displayFeatures: List, appState: JetcasterAppState = rememberJetcasterAppState() ) { - //if (appState.isOnline) { - NavHost( - navController = appState.navController, - startDestination = Screen.Home.route - ) { - composable(Screen.Home.route) { backStackEntry -> - Home( - navigateToPodcastDetails = { podcast -> - appState.navigateToPodcastDetails(podcast.uri, backStackEntry) - }, + // if (appState.isOnline) { + NavHost( + navController = appState.navController, + startDestination = Screen.Home.route + ) { + composable(Screen.Home.route) { backStackEntry -> + Home( + navigateToPodcastDetails = { podcast -> + appState.navigateToPodcastDetails(podcast.uri, backStackEntry) + }, + ) + } + composable(Screen.Player.route) { backStackEntry -> + val playerViewModel: PlayerViewModel = viewModel( + factory = PlayerViewModel.provideFactory( + owner = backStackEntry, + defaultArgs = backStackEntry.arguments ) - } - composable(Screen.Player.route) { backStackEntry -> - val playerViewModel: PlayerViewModel = viewModel( - factory = PlayerViewModel.provideFactory( - owner = backStackEntry, - defaultArgs = backStackEntry.arguments + ) + PlayerScreen( + windowSizeClass, + displayFeatures, + playerViewModel, + onBackPress = appState::navigateBack + ) + } + composable( + route = Screen.PodcastDetails.route, + enterTransition = { + fadeIn( + animationSpec = tween( + 300, easing = LinearEasing ) + ) + slideIntoContainer( + animationSpec = tween(300, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start ) - PlayerScreen( - windowSizeClass, - displayFeatures, - playerViewModel, - onBackPress = appState::navigateBack - ) - } - composable( - route = Screen.PodcastDetails.route, - enterTransition = { - fadeIn( - animationSpec = tween( - 300, easing = LinearEasing - ) - ) + slideIntoContainer( - animationSpec = tween(300, easing = EaseIn), - towards = AnimatedContentTransitionScope.SlideDirection.Start - ) - }, - exitTransition = { - fadeOut( - animationSpec = tween( - 300, easing = LinearEasing - ) - ) + slideOutOfContainer( - animationSpec = tween(300, easing = EaseOut), - towards = AnimatedContentTransitionScope.SlideDirection.End - ) - } - ) { backStackEntry -> - val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( - factory = PodcastDetailsViewModel.provideFactory( - episodeStore = episodeStore, - podcastStore = podcastStore, - episodePlayer = episodePlayer, - owner = backStackEntry, - defaultArgs = backStackEntry.arguments + }, + exitTransition = { + fadeOut( + animationSpec = tween( + 300, easing = LinearEasing ) - ) - PodcastDetailsScreen( - viewModel = podcastDetailsViewModel, - navigateToPlayer = { episodePlayer -> - appState.navigateToPlayer(episodePlayer.uri, backStackEntry) - }, - navigateBack = appState::navigateBack + ) + slideOutOfContainer( + animationSpec = tween(300, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End ) } + ) { backStackEntry -> + val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( + factory = PodcastDetailsViewModel.provideFactory( + episodeStore = episodeStore, + podcastStore = podcastStore, + episodePlayer = episodePlayer, + owner = backStackEntry, + defaultArgs = backStackEntry.arguments + ) + ) + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = { episodePlayer -> + appState.navigateToPlayer(episodePlayer.uri, backStackEntry) + }, + navigateBack = appState::navigateBack + ) } + } // } else { // OfflineDialog { appState.refreshOnline() } // } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index be928e292f..0e4579aa33 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -97,12 +97,12 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 @Composable fun Home( diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt index 357258b8ba..eedf1f708b 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt @@ -40,8 +40,8 @@ class FilterableCategoriesUseCase( .map { categories -> FilterableCategoriesModel( categories = categories.map { it.asExternalModel() }, - selectedCategory = selectedCategory ?: - categories.firstOrNull()?.asExternalModel() + selectedCategory = selectedCategory + ?: categories.firstOrNull()?.asExternalModel() ) } } diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt index b92e11d5c3..766ae3cc0f 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt @@ -1,3 +1,19 @@ +/* + * 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.model import com.example.jetcaster.core.data.database.model.Category diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt index 2650a43546..7a1a3df058 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt @@ -1,3 +1,19 @@ +/* + * 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.model data class LibraryInfo( From 3649e932037b56deb18b31e4a1f257abd1eb25b4 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Thu, 28 Mar 2024 16:09:04 -0400 Subject: [PATCH 055/143] Undo commented out code. --- .../com/example/jetcaster/ui/JetcasterApp.kt | 124 +++++++++--------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index f778a2033e..97f4514e6e 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -49,76 +49,76 @@ fun JetcasterApp( displayFeatures: List, appState: JetcasterAppState = rememberJetcasterAppState() ) { - // if (appState.isOnline) { - NavHost( - navController = appState.navController, - startDestination = Screen.Home.route - ) { - composable(Screen.Home.route) { backStackEntry -> - Home( - navigateToPodcastDetails = { podcast -> - appState.navigateToPodcastDetails(podcast.uri, backStackEntry) - }, - ) - } - composable(Screen.Player.route) { backStackEntry -> - val playerViewModel: PlayerViewModel = viewModel( - factory = PlayerViewModel.provideFactory( - owner = backStackEntry, - defaultArgs = backStackEntry.arguments + if (appState.isOnline) { + NavHost( + navController = appState.navController, + startDestination = Screen.Home.route + ) { + composable(Screen.Home.route) { backStackEntry -> + Home( + navigateToPodcastDetails = { podcast -> + appState.navigateToPodcastDetails(podcast.uri, backStackEntry) + }, ) - ) - PlayerScreen( - windowSizeClass, - displayFeatures, - playerViewModel, - onBackPress = appState::navigateBack - ) - } - composable( - route = Screen.PodcastDetails.route, - enterTransition = { - fadeIn( - animationSpec = tween( - 300, easing = LinearEasing + } + composable(Screen.Player.route) { backStackEntry -> + val playerViewModel: PlayerViewModel = viewModel( + factory = PlayerViewModel.provideFactory( + owner = backStackEntry, + defaultArgs = backStackEntry.arguments ) - ) + slideIntoContainer( - animationSpec = tween(300, easing = EaseIn), - towards = AnimatedContentTransitionScope.SlideDirection.Start ) - }, - exitTransition = { - fadeOut( - animationSpec = tween( - 300, easing = LinearEasing - ) - ) + slideOutOfContainer( - animationSpec = tween(300, easing = EaseOut), - towards = AnimatedContentTransitionScope.SlideDirection.End + PlayerScreen( + windowSizeClass, + displayFeatures, + playerViewModel, + onBackPress = appState::navigateBack ) } - ) { backStackEntry -> - val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( - factory = PodcastDetailsViewModel.provideFactory( - episodeStore = episodeStore, - podcastStore = podcastStore, - episodePlayer = episodePlayer, - owner = backStackEntry, - defaultArgs = backStackEntry.arguments - ) - ) - PodcastDetailsScreen( - viewModel = podcastDetailsViewModel, - navigateToPlayer = { episodePlayer -> - appState.navigateToPlayer(episodePlayer.uri, backStackEntry) + composable( + route = Screen.PodcastDetails.route, + enterTransition = { + fadeIn( + animationSpec = tween( + 300, easing = LinearEasing + ) + ) + slideIntoContainer( + animationSpec = tween(300, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) }, - navigateBack = appState::navigateBack - ) + exitTransition = { + fadeOut( + animationSpec = tween( + 300, easing = LinearEasing + ) + ) + slideOutOfContainer( + animationSpec = tween(300, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) + } + ) { backStackEntry -> + val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( + factory = PodcastDetailsViewModel.provideFactory( + episodeStore = episodeStore, + podcastStore = podcastStore, + episodePlayer = episodePlayer, + owner = backStackEntry, + defaultArgs = backStackEntry.arguments + ) + ) + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = { episodePlayer -> + appState.navigateToPlayer(episodePlayer.uri, backStackEntry) + }, + navigateBack = appState::navigateBack + ) + } } + } else { + OfflineDialog { appState.refreshOnline() } } -// } else { -// OfflineDialog { appState.refreshOnline() } -// } } @Composable From 01a095c13aaaaddd6d5e71c665146c414f921187 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 29 Mar 2024 11:20:33 -0400 Subject: [PATCH 056/143] Fix tests --- .../core/data/domain/FilterableCategoriesUseCaseTest.kt | 3 ++- .../core/data/domain/PodcastCategoryFilterUseCaseTest.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt index 17ea9801c8..539415fe38 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt @@ -17,6 +17,7 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.repository.TestCategoryStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -55,7 +56,7 @@ class FilterableCategoriesUseCaseTest { @Test fun whenSelectedCategory_correctFilterableCategoryIsSelected() = runTest { val selectedCategory = testCategories[2] - val filterableCategories = useCase(selectedCategory).first() + val filterableCategories = useCase(selectedCategory.asExternalModel()).first() assertEquals( selectedCategory, filterableCategories.selectedCategory diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index f4f6dfa32f..eb9bb5ef1f 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -21,13 +21,14 @@ 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.model.asExternalModel import com.example.jetcaster.core.data.repository.TestCategoryStore -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 +import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { @@ -78,7 +79,7 @@ class PodcastCategoryFilterUseCaseTest { @Test fun whenCategoryNotNull_validFlow() = runTest { - val resultFlow = useCase(testCategory) + val resultFlow = useCase(testCategory.asExternalModel()) categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast) categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts) From 6452db8167880158378343700a710c83b2bf6eb0 Mon Sep 17 00:00:00 2001 From: arriolac Date: Fri, 29 Mar 2024 15:22:54 +0000 Subject: [PATCH 057/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/domain/PodcastCategoryFilterUseCaseTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index eb9bb5ef1f..6c903269f3 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -23,12 +23,12 @@ import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.repository.TestCategoryStore +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 -import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { From 1c94c8813e268c5ed87de1fcfa12cd31bf13cc9c Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Sat, 30 Mar 2024 12:21:19 -0400 Subject: [PATCH 058/143] PR comments. --- .../com/example/jetcaster/ui/home/Home.kt | 22 ++++----- .../example/jetcaster/ui/home/PreviewData.kt | 47 ++++++++----------- .../ui/home/category/PodcastCategory.kt | 5 +- .../ui/podcast/PodcastDetailsScreen.kt | 18 ++++--- 4 files changed, 38 insertions(+), 54 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 0e4579aa33..8b20d8da46 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -89,20 +89,18 @@ import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.core.data.model.asExternalModel -import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.home.library.libraryItems import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime @Composable fun Home( @@ -515,21 +513,17 @@ private fun lastUpdated(updated: OffsetDateTime): String { fun PreviewHomeContent() { JetcasterTheme { Home( - featuredPodcasts = PreviewPodcastsWithExtraInfo.map { - it.asExternalModel() - }.toPersistentList(), + featuredPodcasts = PreviewPodcasts.toPersistentList(), isRefreshing = false, homeCategories = HomeCategory.entries, selectedHomeCategory = HomeCategory.Discover, filterableCategoriesModel = FilterableCategoriesModel( - categories = PreviewCategories.map { it.asExternalModel() }, - selectedCategory = PreviewCategories.firstOrNull()?.asExternalModel() + categories = PreviewCategories, + selectedCategory = PreviewCategories.firstOrNull() ), podcastCategoryFilterResult = PodcastCategoryFilterResult( - topPodcasts = PreviewPodcastsWithExtraInfo.map { it.asExternalModel() }, - episodes = PreviewEpisodeToPodcasts.map { - it.asPodcastCategoryEpisode() - } + topPodcasts = PreviewPodcasts, + episodes = PreviewPodcastCategoryEpisodes ), library = LibraryInfo(), onCategorySelected = {}, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index 8705e62736..fb34b901fd 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -16,45 +16,38 @@ package com.example.jetcaster.ui.home -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.model.CategoryInfo +import com.example.jetcaster.core.data.model.EpisodeInfo +import com.example.jetcaster.core.data.model.PodcastCategoryEpisode +import com.example.jetcaster.core.data.model.PodcastInfo import java.time.OffsetDateTime import java.time.ZoneOffset val PreviewCategories = listOf( - Category(name = "Crime"), - Category(name = "News"), - Category(name = "Comedy") + CategoryInfo(id = 1, name = "Crime"), + CategoryInfo(id = 2, name = "News"), + CategoryInfo(id = 3, name = "Comedy") ) val PreviewPodcasts = listOf( - Podcast( + PodcastInfo( uri = "fakeUri://podcast/1", title = "Android Developers Backstage", - author = "Android Developers" + author = "Android Developers", + isSubscribed = true, + lastEpisodeDate = OffsetDateTime.now() ), - Podcast( + PodcastInfo( uri = "fakeUri://podcast/2", title = "Google Developers podcast", - author = "Google Developers" + author = "Google Developers", + lastEpisodeDate = OffsetDateTime.now() ) ) -val PreviewPodcastsWithExtraInfo = PreviewPodcasts.mapIndexed { index, podcast -> - PodcastWithExtraInfo().apply { - this.podcast = podcast - this.lastEpisodeDate = OffsetDateTime.now() - this.isFollowed = index % 2 == 0 - } -} - val PreviewEpisodes = listOf( - Episode( + EpisodeInfo( uri = "fakeUri://episode/1", - podcastUri = PreviewPodcasts[0].uri, title = "Episode 140: Bubbles!", summary = "In this episode, Romain, Chet and Tor talked with Mady Melor and Artur " + "Tsurkan from the System UI team about... Bubbles!", @@ -65,9 +58,9 @@ val PreviewEpisodes = listOf( ) ) -val PreviewEpisodeToPodcasts = listOf( - EpisodeToPodcast().apply { - episode = PreviewEpisodes.first() - _podcasts = PreviewPodcasts - } +val PreviewPodcastCategoryEpisodes = listOf( + PodcastCategoryEpisode( + podcast = PreviewPodcasts[0], + episode = PreviewEpisodes[0], + ) ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index aadcb4a492..6c8ac78144 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -47,7 +47,6 @@ import coil.request.ImageRequest import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts @@ -181,8 +180,8 @@ private fun TopPodcastRowItem( fun PreviewEpisodeListItem() { JetcasterTheme { EpisodeListItem( - episode = PreviewEpisodes[0].asExternalModel(), - podcast = PreviewPodcasts[0].asExternalModel(), + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], onClick = { }, onQueueEpisode = { }, modifier = Modifier.fillMaxWidth() diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index e9e18a2cf3..67932514bd 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +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 @@ -64,7 +65,6 @@ import com.example.jetcaster.R import com.example.jetcaster.core.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts @@ -181,7 +181,7 @@ fun PodcastDetailsHeaderItem( .data(podcast.imageUrl) .crossfade(true) .build(), - contentDescription = stringResource(id = R.string.cd_podcast_image), + contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(148.dp) @@ -260,17 +260,15 @@ fun PodcastDetailsHeaderItemButtons( ) { Row(modifier.padding(top = 16.dp)) { Button( - onClick = onClick + onClick = onClick, + modifier = Modifier.semantics(mergeDescendants = true) { } ) { Icon( imageVector = if (isSubscribed) Icons.Default.Check else Icons.Default.Add, - contentDescription = if (isSubscribed) - stringResource(id = R.string.unsubscribe) - else - stringResource(id = R.string.subscribe) + contentDescription = null ) Text( text = if (isSubscribed) @@ -317,7 +315,7 @@ fun PodcastDetailsTopAppBar( @Composable fun PodcastDetailsHeaderItemPreview() { PodcastDetailsHeaderItem( - podcast = PreviewPodcasts[0].asExternalModel(), + podcast = PreviewPodcasts[0], toggleSubscribe = { }, ) } @@ -326,8 +324,8 @@ fun PodcastDetailsHeaderItemPreview() { @Composable fun PodcastDetailsScreenPreview() { PodcastDetailsScreen( - podcast = PreviewPodcasts[0].asExternalModel(), - episodes = PreviewEpisodes.map { it.asExternalModel() }, + podcast = PreviewPodcasts[0], + episodes = PreviewEpisodes, toggleSubscribe = { }, onQueueEpisode = { }, navigateToPlayer = { }, From 2764e065c0d4862108ae03b7444d83ad6af17054 Mon Sep 17 00:00:00 2001 From: arriolac Date: Sat, 30 Mar 2024 16:24:12 +0000 Subject: [PATCH 059/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 8b20d8da46..440255f6dd 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -95,12 +95,12 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 @Composable fun Home( From 61ef2c94b63f02ae729cccda23a1b494482b4db1 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Mon, 1 Apr 2024 16:04:31 +0900 Subject: [PATCH 060/143] Add a screen to show the details of the selected episode --- .../example/jetcaster/tv/ui/JetcasterApp.kt | 24 ++- .../jetcaster/tv/ui/JetcasterAppState.kt | 20 ++- .../jetcaster/tv/ui/component/Background.kt | 58 +++++++ .../jetcaster/tv/ui/component/Button.kt | 80 +++++++++ .../jetcaster/tv/ui/component/Catalog.kt | 31 +++- .../jetcaster/tv/ui/component/ErrorState.kt | 2 +- .../jetcaster/tv/ui/component/Thumbnail.kt | 50 ++++++ .../tv/ui/discover/DiscoverScreen.kt | 57 +++++-- .../jetcaster/tv/ui/episode/EpisodeScreen.kt | 154 ++++++++++++++++++ .../tv/ui/episode/EpisodeScreenViewModel.kt | 94 +++++++++++ .../jetcaster/tv/ui/library/LibraryScreen.kt | 42 ++++- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 88 ++++------ .../tv/ui/podcast/PodcastScreenViewModel.kt | 9 +- .../jetcaster/tv/ui/search/SearchScreen.kt | 10 +- .../example/jetcaster/tv/ui/theme/Space.kt | 29 +++- .../tv-app/src/main/res/values/strings.xml | 1 + 16 files changed, 649 insertions(+), 100 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index f11ff0d8c6..19025262e0 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -39,6 +39,8 @@ 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.episode.EpisodeScreenViewModel import com.example.jetcaster.tv.ui.library.LibraryScreen import com.example.jetcaster.tv.ui.podcast.PodcastScreen import com.example.jetcaster.tv.ui.podcast.PodcastScreenViewModel @@ -124,6 +126,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { showPodcastDetails = { jetcasterAppState.showPodcastDetails(it.uri) }, + showEpisodeDetails = { + jetcasterAppState.showEpisodeDetails(it.episode.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) .fillMaxSize() @@ -138,6 +143,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { showPodcastDetails = { jetcasterAppState.showPodcastDetails(it.podcast.uri) }, + showEpisodeDetails = { + jetcasterAppState.showEpisodeDetails(it.episode.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) .fillMaxSize() @@ -164,12 +172,26 @@ private fun Route(jetcasterAppState: JetcasterAppState) { podcastScreenViewModel = podcastScreenViewModel, backToHomeScreen = jetcasterAppState::navigateToDiscover, playEpisode = {}, + showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.episode.uri) }, modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.podcastDetails.intoPaddingValues()) + .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) .fillMaxSize(), ) } + composable(Screen.Episode.route) { + val episodeScreenViewModel: EpisodeScreenViewModel = viewModel( + factory = EpisodeScreenViewModel.factory + ) + EpisodeScreen( + playEpisode = { + jetcasterAppState.playEpisode(it.uri) + }, + backToHome = jetcasterAppState::navigateToDiscover, + episodeScreenViewModel = episodeScreenViewModel, + ) + } + composable(Screen.Player.route) { Text(text = "Player") } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt index 23ac10a355..a4153a8335 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -51,6 +51,12 @@ class JetcasterAppState( navHostController.navigate(screen.route) } + fun showEpisodeDetails(episodeUri: String) { + val encodeUrl = Uri.encode(episodeUri) + val screen = Screen.Episode(encodeUrl) + navHostController.navigate(screen.route) + } + fun playEpisode(episodeUri: String) { val screen = Screen.Player(episodeUri) navHostController.navigate(screen.route) @@ -97,7 +103,17 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "podcast" - private const val PARAMETER_NAME = "podcastUri" + 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}" } } @@ -107,7 +123,7 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "player" - private const val PARAMETER_NAME = "episodeUri" + const val PARAMETER_NAME = "episodeUri" override val route = "$ROOT/{$PARAMETER_NAME}" } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt new file mode 100644 index 0000000000..9c339d2cff --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt @@ -0,0 +1,58 @@ +/* + * 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.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 +import com.example.jetcaster.core.data.database.model.Podcast + +@Composable +internal fun Background( + podcast: Podcast, + modifier: Modifier = Modifier, + overlay: DrawScope.() -> Unit = { + val brush = Brush.radialGradient( + listOf(Color.Black, Color.Transparent), + center = Offset(0f, size.height), + radius = size.width * 1.5f + ) + drawRect(brush, blendMode = BlendMode.Multiply) + } +) { + AsyncImage( + model = podcast.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .fillMaxWidth() + .drawWithCache { + onDrawWithContent { + drawContent() + overlay() + } + } + ) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt new file mode 100644 index 0000000000..aef3c09787 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt @@ -0,0 +1,80 @@ +/* + * 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.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun PlayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) = + ButtonWithIcon( + icon = Icons.Outlined.PlayArrow, + label = stringResource(R.string.label_play), + onClick = onClick, + modifier = modifier + ) + +@OptIn(ExperimentalTvMaterial3Api::class) +@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), + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun ButtonWithIcon( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) = + Button(onClick = onClick, modifier = modifier) { + Icon( + icon, + contentDescription = null, + modifier = Modifier + .width(ButtonDefaults.IconSize) + .padding(end = ButtonDefaults.IconSpacing) + ) + Text(text = label) + } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index c3d0487d1d..b9b90a84d3 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -26,14 +26,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width 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.focusRestorer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyListState import androidx.tv.foundation.lazy.list.TvLazyRow import androidx.tv.foundation.lazy.list.items +import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.Card import androidx.tv.material3.CardScale import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -55,14 +59,17 @@ internal fun Catalog( podcastList: PodcastList, latestEpisodeList: EpisodeList, onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, + state: TvLazyListState = rememberTvLazyListState(), header: (@Composable () -> Unit)? = null, ) { TvLazyColumn( modifier = modifier, contentPadding = JetcasterAppDefaults.overScanMargin.catalog.intoPaddingValues(), verticalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + Arrangement.spacedBy(JetcasterAppDefaults.gap.section), + state = state, ) { if (header != null) { item { header() } @@ -77,13 +84,14 @@ internal fun Catalog( item { LatestEpisodeSection( episodeList = latestEpisodeList, - onEpisodeSelected = {}, + onEpisodeSelected = onEpisodeSelected, title = stringResource(R.string.label_latest_episode) ) } } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun PodcastSection( podcastList: PodcastList, @@ -95,10 +103,15 @@ private fun PodcastSection( title = title, modifier = modifier ) { - PodcastRow(podcastList = podcastList, onPodcastSelected = onPodcastSelected) + PodcastRow( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + modifier = Modifier.focusRestorer() + ) } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun LatestEpisodeSection( episodeList: EpisodeList, @@ -110,7 +123,11 @@ private fun LatestEpisodeSection( modifier = modifier, title = title ) { - EpisodeRow(episodeList = episodeList, onEpisodeSelected = onEpisodeSelected) + EpisodeRow( + episodeList = episodeList, + onEpisodeSelected = onEpisodeSelected, + modifier = Modifier.focusRestorer() + ) } } @@ -141,7 +158,7 @@ private fun PodcastRow( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), ) { TvLazyRow( contentPadding = contentPadding, @@ -189,7 +206,7 @@ private fun EpisodeRow( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + Arrangement.spacedBy(JetcasterAppDefaults.gap.episodeRow), ) { TvLazyRow( contentPadding = contentPadding, @@ -261,7 +278,7 @@ private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modi Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) if (duration != null) { Spacer( - modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) + modifier = Modifier.height(JetcasterAppDefaults.gap.podcastRow * 0.8f) ) EpisodeDataAndDuration(offsetDateTime = publishedDate, duration = duration) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt index 8e655a35b2..f70e6b3557 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt @@ -53,7 +53,7 @@ fun ErrorState( Button( onClick = backToHome, modifier - .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .padding(top = JetcasterAppDefaults.gap.podcastRow) .focusRequester(focusRequester) ) { Text(text = stringResource(R.string.label_back_to_home)) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt new file mode 100644 index 0000000000..1f74b61aa9 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt @@ -0,0 +1,50 @@ +/* + * 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 coil.compose.AsyncImage +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun Thumbnail( + podcast: Podcast, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(12.dp), + size: DpSize = DpSize( + JetcasterAppDefaults.cardWidth.medium, + JetcasterAppDefaults.cardWidth.medium + ), + contentScale: ContentScale = ContentScale.Crop +) = + AsyncImage( + model = podcast.imageUrl, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .size(size) + .clip(shape) + .then(modifier) + ) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 3d84a42a6a..2f5548b2e7 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -26,13 +26,18 @@ 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.focus.focusRestorer import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.foundation.lazy.list.TvLazyListState +import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text 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.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.model.CategoryList @@ -45,6 +50,7 @@ import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable fun DiscoverScreen( showPodcastDetails: (Podcast) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, discoverScreenViewModel: DiscoverScreenViewModel = viewModel() ) { @@ -67,6 +73,7 @@ fun DiscoverScreen( latestEpisodeList = s.latestEpisodeList, onPodcastSelected = { showPodcastDetails(it.podcast) }, onCategorySelected = discoverScreenViewModel::selectCategory, + onEpisodeSelected = showEpisodeDetails, modifier = Modifier .fillMaxSize() .then(modifier) @@ -83,34 +90,60 @@ private fun CatalogWithCategorySelection( selectedCategory: Category, latestEpisodeList: EpisodeList, onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, onCategorySelected: (Category) -> Unit, modifier: Modifier = Modifier, + state: TvLazyListState = rememberTvLazyListState(), ) { - val tabRow = remember(categoryList) { FocusRequester() } - + val (focusRequester, selectedTab) = remember { + FocusRequester.createRefs() + } LaunchedEffect(Unit) { - tabRow.requestFocus() + focusRequester.requestFocus() } + val selectedTabIndex = categoryList.indexOf(selectedCategory) Catalog( podcastList = podcastList, latestEpisodeList = latestEpisodeList, - onPodcastSelected = onPodcastSelected, - modifier = modifier, + onPodcastSelected = { + focusRequester.saveFocusedChild() + onPodcastSelected(it) + }, + onEpisodeSelected = { + focusRequester.saveFocusedChild() + onEpisodeSelected(it) + }, + modifier = modifier + .focusRequester(focusRequester) + .focusRestorer(), + state = state, ) { + TabRow( - selectedTabIndex = categoryList.indexOf(selectedCategory), - modifier = Modifier.focusRequester(tabRow) + selectedTabIndex = selectedTabIndex, + modifier = Modifier.focusProperties { + enter = { + selectedTab + } + } ) { - categoryList.forEach { + categoryList.forEachIndexed { index, category -> + val tabModifier = if (selectedTabIndex == index) { + Modifier.focusRequester(selectedTab) + } else { + Modifier + } + Tab( - selected = it == selectedCategory, + selected = index == selectedTabIndex, onFocus = { - onCategorySelected(it) - } + onCategorySelected(category) + }, + modifier = tabModifier, ) { Text( - text = it.name, + text = category.name, modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) ) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt new file mode 100644 index 0000000000..76bdba6d50 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -0,0 +1,154 @@ +/* + * 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.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.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.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.tv.ui.component.Background +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.theme.JetcasterAppDefaults + +@Composable +fun EpisodeScreen( + playEpisode: (Episode) -> Unit, + backToHome: () -> Unit, + modifier: Modifier = Modifier, + episodeScreenViewModel: EpisodeScreenViewModel = viewModel() +) { + + val uiState by episodeScreenViewModel.uiStateFlow.collectAsState() + + when (val s = uiState) { + EpisodeScreenUiState.Loading -> Loading(modifier = modifier) + EpisodeScreenUiState.Error -> ErrorState(backToHome = backToHome, modifier = modifier) + is EpisodeScreenUiState.Ready -> EpisodeDetailsWithBackground( + episodeToPodcast = s.episodeToPodcast, + playEpisode = playEpisode, + addPlayList = episodeScreenViewModel::addPlayList + ) + } +} + +@Composable +private fun EpisodeDetailsWithBackground( + episodeToPodcast: EpisodeToPodcast, + playEpisode: (Episode) -> Unit, + addPlayList: (Episode) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Background(podcast = episodeToPodcast.podcast, modifier = Modifier.fillMaxSize()) + EpisodeDetails( + episodeToPodcast = episodeToPodcast, + playEpisode = playEpisode, + addPlayList = addPlayList, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.episode.intoPaddingValues()) + ) + } +} + +@Composable +private fun EpisodeDetails( + episodeToPodcast: EpisodeToPodcast, + playEpisode: (Episode) -> Unit, + addPlayList: (Episode) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn), + ) { + Thumbnail( + podcast = episodeToPodcast.podcast, + size = JetcasterAppDefaults.thumbnailSize.episode + ) + EpisodeInfo( + episode = episodeToPodcast.episode, + playEpisode = playEpisode, + addPlayList = addPlayList, + modifier = Modifier.weight(1f) + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeInfo( + episode: Episode, + playEpisode: (Episode) -> Unit, + addPlayList: (Episode) -> Unit, + modifier: Modifier = Modifier +) { + val author = episode.author + val duration = episode.duration + val summary = episode.summary + + Column(modifier) { + if (author != null) { + Text(text = author, style = MaterialTheme.typography.bodySmall) + } + Text(text = episode.title, style = MaterialTheme.typography.headlineLarge) + if (duration != null) { + EpisodeDataAndDuration(offsetDateTime = episode.published, duration = duration) + } + if (summary != null) { + Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) + Text(text = summary, softWrap = true, maxLines = 5, overflow = TextOverflow.Ellipsis) + } + Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) + Controls(playEpisode = { playEpisode(episode) }, addPlayList = { addPlayList(episode) }) + } +} + +@Composable +private fun Controls( + playEpisode: () -> Unit, + addPlayList: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + modifier = modifier + ) { + PlayButton(onClick = playEpisode) + EnqueueButton(onClick = addPlayList) + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt new file mode 100644 index 0000000000..e3bacc7480 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -0,0 +1,94 @@ +/* + * 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.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.tv.ui.Screen +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class EpisodeScreenViewModel( + handle: SavedStateHandle, + podcastsRepository: PodcastsRepository = Graph.podcastRepository, + episodeStore: EpisodeStore = Graph.episodeStore, +) : ViewModel() { + + private val episodeUri = handle.get(Screen.Episode.PARAMETER_NAME) + + private val episodeToPodcastFlow = if (episodeUri != null) { + episodeStore.episodeAndPodcastWithUri(episodeUri) + } else { + flowOf(null) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + val uiStateFlow = episodeToPodcastFlow.map { + if (it != null) { + EpisodeScreenUiState.Ready(it) + } else { + EpisodeScreenUiState.Error + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + EpisodeScreenUiState.Loading + ) + + fun addPlayList(episode: Episode) { + } + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } + + companion object { + @Suppress("UNCHECKED_CAST") + val factory = object : AbstractSavedStateViewModelFactory() { + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return EpisodeScreenViewModel( + handle + ) as T + } + } + } +} + +sealed interface EpisodeScreenUiState { + data object Loading : EpisodeScreenUiState + data object Error : EpisodeScreenUiState + data class Ready(val episodeToPodcast: EpisodeToPodcast) : EpisodeScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index 4f77143de5..bdbf1b5625 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -25,17 +25,22 @@ 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.lifecycle.viewmodel.compose.viewModel import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo 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 @@ -45,6 +50,7 @@ fun LibraryScreen( modifier: Modifier = Modifier, navigateToDiscover: () -> Unit, showPodcastDetails: (PodcastWithExtraInfo) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, libraryScreenViewModel: LibraryScreenViewModel = viewModel() ) { val uiState by libraryScreenViewModel.uiState.collectAsState() @@ -54,15 +60,41 @@ fun LibraryScreen( NavigateToDiscover(onNavigationRequested = navigateToDiscover, modifier = modifier) } - is LibraryScreenUiState.Ready -> Catalog( + is LibraryScreenUiState.Ready -> Library( podcastList = s.subscribedPodcastList, - latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = showPodcastDetails, - modifier = modifier + episodeList = s.latestEpisodeList, + showPodcastDetails = showPodcastDetails, + showEpisodeDetails = showEpisodeDetails, + modifier = modifier, ) } } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Library( + podcastList: PodcastList, + episodeList: EpisodeList, + showPodcastDetails: (PodcastWithExtraInfo) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Catalog( + podcastList = podcastList, + latestEpisodeList = episodeList, + onPodcastSelected = showPodcastDetails, + onEpisodeSelected = showEpisodeDetails, + modifier = modifier + .focusRequester(focusRequester) + .focusRestorer() + ) +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun NavigateToDiscover( @@ -83,7 +115,7 @@ private fun NavigateToDiscover( Button( onClick = onNavigationRequested, modifier = Modifier - .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .padding(top = JetcasterAppDefaults.gap.podcastRow) .focusRequester(focusRequester) ) { Text(text = stringResource(id = R.string.label_navigate_to_discover)) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index 6d22f685d1..6f3f21d396 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -21,12 +21,9 @@ 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.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -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 @@ -35,18 +32,12 @@ 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.draw.clip -import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer -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.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -57,16 +48,17 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import coil.compose.AsyncImage 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.tv.R import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.component.Background import com.example.jetcaster.tv.ui.component.ButtonWithIcon 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.Thumbnail import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable @@ -74,6 +66,7 @@ fun PodcastScreen( podcastScreenViewModel: PodcastScreenViewModel, backToHomeScreen: () -> Unit, playEpisode: (Episode) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, ) { val uiState by podcastScreenViewModel.uiStateFlow.collectAsState() @@ -87,7 +80,7 @@ fun PodcastScreen( subscribe = podcastScreenViewModel::subscribe, unsubscribe = podcastScreenViewModel::unsubscribe, playEpisode = playEpisode, - modifier = modifier, + showEpisodeDetails = showEpisodeDetails, ) } } @@ -100,10 +93,11 @@ private fun PodcastDetailsWithBackground( subscribe: (Podcast, Boolean) -> Unit, unsubscribe: (Podcast, Boolean) -> Unit, playEpisode: (Episode) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() } ) { - Box { + Box(modifier = modifier) { Background(podcast = podcast) PodcastDetails( podcast = podcast, @@ -113,12 +107,15 @@ private fun PodcastDetailsWithBackground( unsubscribe = unsubscribe, playEpisode = playEpisode, focusRequester = focusRequester, - modifier = modifier + showEpisodeDetails = showEpisodeDetails, + modifier = Modifier + .fillMaxSize() + .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) ) } } -@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun PodcastDetails( podcast: Podcast, @@ -127,13 +124,14 @@ private fun PodcastDetails( subscribe: (Podcast, Boolean) -> Unit, unsubscribe: (Podcast, Boolean) -> Unit, playEpisode: (Episode) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() } ) { Row( modifier = modifier, horizontalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn) ) { PodcastInfo( podcast = podcast, @@ -145,6 +143,7 @@ private fun PodcastDetails( PodcastEpisodeList( episodeList = episodeList, onEpisodeSelected = { playEpisode(it.episode) }, + onDetailsRequested = showEpisodeDetails, modifier = Modifier .focusRequester(focusRequester) .focusRestorer() @@ -157,31 +156,6 @@ private fun PodcastDetails( } } -@Composable -private fun Background( - podcast: Podcast, - modifier: Modifier = Modifier, -) { - AsyncImage( - model = podcast.imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - .fillMaxWidth() - .drawWithCache { - val overlay = Brush.radialGradient( - listOf(Color.Black, Color.Transparent), - center = Offset(0f, size.height), - radius = size.width * 1.5f - ) - onDrawWithContent { - drawContent() - drawRect(overlay, blendMode = BlendMode.Multiply) - } - } - ) -} - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun PodcastInfo( @@ -195,17 +169,7 @@ private fun PodcastInfo( val description = podcast.description Column(modifier = modifier) { - AsyncImage( - model = podcast.imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .width(JetcasterAppDefaults.cardWidth.medium) - .aspectRatio(1f) - .clip( - RoundedCornerShape(12.dp) - ) - ) + Thumbnail(podcast = podcast) Spacer(modifier = Modifier.height(16.dp)) if (author != null) { Text( @@ -231,7 +195,7 @@ private fun PodcastInfo( subscribe, unsubscribe, modifier = Modifier - .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .padding(top = JetcasterAppDefaults.gap.podcastRow) ) } } @@ -273,14 +237,19 @@ private fun ToggleSubscriptionButton( private fun PodcastEpisodeList( episodeList: EpisodeList, onEpisodeSelected: (EpisodeToPodcast) -> Unit, + onDetailsRequested: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier ) { TvLazyColumn( - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), modifier = modifier ) { items(episodeList) { - EpisodeListItem(episodeToPodcast = it, onEpisodeSelected = onEpisodeSelected) + EpisodeListItem( + episodeToPodcast = it, + onEpisodeSelected = onEpisodeSelected, + onInfoClicked = onDetailsRequested + ) } } } @@ -290,16 +259,19 @@ private fun PodcastEpisodeList( private fun EpisodeListItem( episodeToPodcast: EpisodeToPodcast, onEpisodeSelected: (EpisodeToPodcast) -> Unit, + onInfoClicked: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, selected: Boolean = false ) { ListItem( selected = selected, - onClick = { onEpisodeSelected(episodeToPodcast) }, + onClick = { onInfoClicked(episodeToPodcast) }, + onLongClick = { onEpisodeSelected(episodeToPodcast) }, modifier = modifier ) { Row( - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp) + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { EpisodeMetaData(episode = episodeToPodcast.episode) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt index 5ea8d84690..8b39200abd 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -25,6 +25,7 @@ import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.Screen import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -40,9 +41,13 @@ class PodcastScreenViewModel( episodeStore: EpisodeStore = Graph.episodeStore, ) : ViewModel() { - private val podcastUri = handle.get("podcastUri") ?: "uri://no/podcast/is/specified" + private val podcastUri = handle.get(Screen.Podcast.PARAMETER_NAME) - private val podcastFlow = podcastStore.podcastWithUri(podcastUri).stateIn( + private val podcastFlow = if (podcastUri != null) { + podcastStore.podcastWithUri(podcastUri) + } else { + flowOf(null) + }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5_000), null diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt index 304b8429b3..67295c7537 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -162,7 +162,7 @@ private fun Controls( } Column( - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.itemGap), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), modifier = modifier ) { KeywordInput( @@ -232,8 +232,8 @@ private fun CategorySelection( ) { FlowRow( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.chipGap), - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.chipGap), + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip), ) { categorySelectionList.forEach { FilterChip( @@ -262,8 +262,8 @@ private fun SearchResult( TvLazyVerticalGrid( columns = TvGridCells.Fixed(4), horizontalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), modifier = modifier, ) { item(span = { TvGridItemSpan(maxLineSpan) }) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt index 538d191885..fda021d68c 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -18,20 +18,28 @@ 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 gapSettings = GapSettings() + val gap = GapSettings() val cardWidth = CardWidth() val padding = PaddingSettings() + val thumbnailSize = ThumbnailSize() } internal data class OverScanMarginSettings( val default: OverScanMargin = OverScanMargin(), - val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), + val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), + val episode: OverScanMargin = OverScanMargin(start = 80.dp, end = 80.dp), val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), - val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp) + val podcast: OverScanMargin = OverScanMargin( + top = 40.dp, + bottom = 40.dp, + start = 80.dp, + end = 80.dp + ), ) internal data class OverScanMargin( @@ -51,14 +59,21 @@ internal data class CardWidth( val small: Dp = 124.dp ) +internal data class ThumbnailSize( + val episode: DpSize = DpSize(266.dp, 266.dp), +) + internal data class PaddingSettings( val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) ) internal data class GapSettings( - val catalogItemGap: Dp = 20.dp, - val catalogSectionGap: Dp = 40.dp, - val itemGap: Dp = 16.dp, - val chipGap: Dp = 8.dp + val chip: Dp = 8.dp, + val episodeRow: Dp = 20.dp, + val item: Dp = 16.dp, + val paragraph: Dp = 16.dp, + val podcastRow: Dp = 20.dp, + val section: Dp = 40.dp, + val twoColumn: Dp = 40.dp, ) diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 453c63048d..8baaeeac10 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -39,6 +39,7 @@ Discover the podcasts Back to Home Search podcasts by keyword + Add to playlist Updated a while ago From 41cee47cdb434360ec4080c1ab14489c31e54d45 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 09:18:03 -0700 Subject: [PATCH 061/143] Navigate to player when tapping episode item. --- .../main/java/com/example/jetcaster/ui/JetcasterApp.kt | 3 +++ .../main/java/com/example/jetcaster/ui/home/Home.kt | 10 +++++++++- .../jetcaster/ui/home/category/PodcastCategory.kt | 4 +++- .../com/example/jetcaster/ui/home/discover/Discover.kt | 3 +++ .../com/example/jetcaster/ui/home/library/Library.kt | 6 +++--- .../jetcaster/ui/podcast/PodcastDetailsScreen.kt | 2 +- .../com/example/jetcaster/ui/shared/EpisodeListItem.kt | 4 ++-- 7 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 97f4514e6e..facd39186a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -59,6 +59,9 @@ fun JetcasterApp( navigateToPodcastDetails = { podcast -> appState.navigateToPodcastDetails(podcast.uri, backStackEntry) }, + navigateToPlayer = { episode -> + appState.navigateToPlayer(episode.uri, backStackEntry) + } ) } composable(Screen.Player.route) { backStackEntry -> diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 440255f6dd..ee52a180bc 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -84,6 +84,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.data.model.CategoryInfo +import com.example.jetcaster.core.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode @@ -105,6 +106,7 @@ import kotlinx.coroutines.launch @Composable fun Home( navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, viewModel: HomeViewModel = viewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() @@ -121,6 +123,7 @@ fun Home( onCategorySelected = viewModel::onCategorySelected, onPodcastUnfollowed = viewModel::onPodcastUnfollowed, navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, onQueueEpisode = viewModel::onQueueEpisode, @@ -187,6 +190,7 @@ fun Home( onHomeCategorySelected: (HomeCategory) -> Unit, onCategorySelected: (CategoryInfo) -> Unit, navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, onTogglePodcastFollowed: (PodcastInfo) -> Unit, onLibraryPodcastSelected: (PodcastInfo?) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, @@ -231,6 +235,7 @@ fun Home( onHomeCategorySelected = onHomeCategorySelected, onCategorySelected = onCategorySelected, navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, onTogglePodcastFollowed = onTogglePodcastFollowed, onLibraryPodcastSelected = onLibraryPodcastSelected, onQueueEpisode = { @@ -259,6 +264,7 @@ private fun HomeContent( onHomeCategorySelected: (HomeCategory) -> Unit, onCategorySelected: (CategoryInfo) -> Unit, navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, onTogglePodcastFollowed: (PodcastInfo) -> Unit, onLibraryPodcastSelected: (PodcastInfo?) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, @@ -308,7 +314,7 @@ private fun HomeContent( HomeCategory.Library -> { libraryItems( library = library, - navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, onQueueEpisode = onQueueEpisode ) } @@ -318,6 +324,7 @@ private fun HomeContent( filterableCategoriesModel = filterableCategoriesModel, podcastCategoryFilterResult = podcastCategoryFilterResult, navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, onCategorySelected = onCategorySelected, onTogglePodcastFollowed = onTogglePodcastFollowed, onQueueEpisode = onQueueEpisode @@ -529,6 +536,7 @@ fun PreviewHomeContent() { onCategorySelected = {}, onPodcastUnfollowed = {}, navigateToPodcastDetails = {}, + navigateToPlayer = {}, onHomeCategorySelected = {}, onTogglePodcastFollowed = {}, onLibraryPodcastSelected = {}, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index 6c8ac78144..a7885e5318 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest +import com.example.jetcaster.core.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.PodcastInfo @@ -57,6 +58,7 @@ import com.example.jetcaster.util.ToggleFollowPodcastIconButton fun LazyListScope.podcastCategory( podcastCategoryFilterResult: PodcastCategoryFilterResult, navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, onTogglePodcastFollowed: (PodcastInfo) -> Unit, ) { @@ -73,7 +75,7 @@ fun LazyListScope.podcastCategory( EpisodeListItem( episode = item.episode, podcast = item.podcast, - onClick = navigateToPodcastDetails, + onClick = navigateToPlayer, onQueueEpisode = onQueueEpisode, modifier = Modifier.fillParentMaxWidth() ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 78113f6e72..7a5632c3c5 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.jetcaster.R import com.example.jetcaster.core.data.model.CategoryInfo +import com.example.jetcaster.core.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult @@ -49,6 +50,7 @@ fun LazyListScope.discoverItems( filterableCategoriesModel: FilterableCategoriesModel, podcastCategoryFilterResult: PodcastCategoryFilterResult, navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, onCategorySelected: (CategoryInfo) -> Unit, onTogglePodcastFollowed: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, @@ -73,6 +75,7 @@ fun LazyListScope.discoverItems( podcastCategory( podcastCategoryFilterResult = podcastCategoryFilterResult, navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, onTogglePodcastFollowed = onTogglePodcastFollowed, onQueueEpisode = onQueueEpisode, ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index 91a58499c4..afb2c26108 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -25,15 +25,15 @@ 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.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastInfo import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.shared.EpisodeListItem fun LazyListScope.libraryItems( library: LibraryInfo, - navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit ) { val podcast = library.podcast @@ -60,7 +60,7 @@ fun LazyListScope.libraryItems( EpisodeListItem( episode = item, podcast = podcast, - onClick = navigateToPodcastDetails, + onClick = navigateToPlayer, onQueueEpisode = onQueueEpisode, modifier = Modifier.fillParentMaxWidth(), showDivider = index != 0 diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index 67932514bd..80808ab3c3 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -154,7 +154,7 @@ fun PodcastDetailsContent( EpisodeListItem( episode = episode, podcast = podcast, - onClick = { navigateToPlayer(episode) }, + onClick = navigateToPlayer, onQueueEpisode = onQueueEpisode, modifier = Modifier.fillMaxWidth(), showPodcastImage = false diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index 4a51977cc5..74450a24ee 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -61,7 +61,7 @@ import java.time.format.FormatStyle fun EpisodeListItem( episode: EpisodeInfo, podcast: PodcastInfo, - onClick: (PodcastInfo) -> Unit, + onClick: (EpisodeInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, showDivider: Boolean = true, @@ -69,7 +69,7 @@ fun EpisodeListItem( ) { ConstraintLayout( modifier = modifier.clickable { - onClick(podcast) + onClick(episode) } ) { val ( From 5d922e1b751b2b507f1e708b6f8948b495cf72cc Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 29 Mar 2024 09:27:55 -0400 Subject: [PATCH 062/143] CAMAL --- Jetcaster/app/build.gradle.kts | 3 + .../com/example/jetcaster/ui/home/Home.kt | 88 ++++++++++++++----- .../ui/podcast/PodcastDetailsViewModel.kt | 15 +++- .../jetcaster/ui/shared/EpisodeListItem.kt | 4 +- Jetcaster/gradle/libs.versions.toml | 7 +- 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index e4fe8c86e8..a0c8428b9c 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -101,6 +101,9 @@ dependencies { implementation(libs.androidx.compose.foundation) 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.material3.window) implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.tooling.preview) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index ee52a180bc..6270eaa7c3 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -18,6 +18,7 @@ package com.example.jetcaster.ui.home +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -56,13 +57,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabPosition import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -92,17 +97,20 @@ import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.PodcastInfo 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.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun Home( navigateToPodcastDetails: (PodcastInfo) -> Unit, @@ -110,26 +118,60 @@ fun Home( viewModel: HomeViewModel = viewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() - Surface(Modifier.fillMaxSize()) { - Home( - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - filterableCategoriesModel = viewState.filterableCategoriesModel, - podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, - library = viewState.library, - onHomeCategorySelected = viewModel::onHomeCategorySelected, - onCategorySelected = viewModel::onCategorySelected, - onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - navigateToPodcastDetails = navigateToPodcastDetails, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, - onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, - onQueueEpisode = viewModel::onQueueEpisode, - modifier = Modifier.fillMaxSize() - ) + val navigator = rememberSupportingPaneScaffoldNavigator( + isDestinationHistoryAware = false + ) + BackHandler(enabled = navigator.canNavigateBack()) { + navigator.navigateBack() } + SupportingPaneScaffold( + value = navigator.scaffoldValue, + directive = navigator.scaffoldDirective, + supportingPane = { + val podcastUri = navigator.currentDestination?.content ?: + viewState.featuredPodcasts.firstOrNull()?.uri + AnimatedPane { + if (podcastUri.isNullOrEmpty()) { + // TODO + Text(text = "") + } else { + val podcastDetailsViewModel = PodcastDetailsViewModel(podcastUri = podcastUri) + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = navigateToPlayer, + navigateBack = { + if (navigator.canNavigateBack()) { + navigator.navigateBack() + } + } + ) + } + } + }, + mainPane = { + Home( + featuredPodcasts = viewState.featuredPodcasts, + isRefreshing = viewState.refreshing, + homeCategories = viewState.homeCategories, + selectedHomeCategory = viewState.selectedHomeCategory, + filterableCategoriesModel = viewState.filterableCategoriesModel, + podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, + library = viewState.library, + onHomeCategorySelected = viewModel::onHomeCategorySelected, + onCategorySelected = viewModel::onCategorySelected, + onPodcastUnfollowed = viewModel::onPodcastUnfollowed, + navigateToPodcastDetails = { + navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri) + }, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, + onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, + onQueueEpisode = viewModel::onQueueEpisode, + modifier = Modifier.fillMaxSize() + ) + }, + modifier = Modifier.fillMaxSize() + ) } @OptIn(ExperimentalMaterial3Api::class) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index a06c519c76..6fa3546920 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -50,11 +50,20 @@ class PodcastDetailsViewModel( private val episodeStore: EpisodeStore = Graph.episodeStore, private val episodePlayer: EpisodePlayer = Graph.episodePlayer, private val podcastStore: PodcastStore = Graph.podcastStore, - savedStateHandle: SavedStateHandle + private val podcastUri: String ) : ViewModel() { - private val podcastUri: String = - Uri.decode(savedStateHandle.get(Screen.ARG_PODCAST_URI)!!) + constructor( + episodeStore: EpisodeStore = Graph.episodeStore, + episodePlayer: EpisodePlayer = Graph.episodePlayer, + podcastStore: PodcastStore = Graph.podcastStore, + savedStateHandle: SavedStateHandle + ) : this( + episodeStore = episodeStore, + episodePlayer = episodePlayer, + podcastStore = podcastStore, + podcastUri = Uri.decode(savedStateHandle.get(Screen.ARG_PODCAST_URI)!!) + ) val state: StateFlow = combine( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index 74450a24ee..c2a8ab6225 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -25,12 +25,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.rounded.PlayCircleFilled -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -153,7 +153,7 @@ fun EpisodeListItem( modifier = Modifier .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp) + indication = ripple(bounded = false, radius = 24.dp) ) { /* TODO */ } .size(48.dp) .padding(6.dp) diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index 461fe0806a..6b917df0d7 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -10,6 +10,8 @@ androidx-appcompat = "1.6.1" androidx-benchmark = "1.2.3" androidx-benchmark-junit4 = "1.2.3" androidx-compose-bom = "2024.03.00" +androidx-compose-material3-adaptive = "1.0.0-alpha09" +androidx-compose-material3-latest = "1.3.0-alpha03" androidx-constraintlayout = "1.0.1" androidx-corektx = "1.13.0-beta01" androidx-glance = "1.0.0" @@ -85,7 +87,10 @@ androidx-compose-foundation = { module = "androidx.compose.foundation:foundation androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } androidx-compose-material = { module = "androidx.compose.material:material" } androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } -androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3-latest" } +androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidx-compose-material3-adaptive" } +androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "androidx-compose-material3-adaptive" } +androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "androidx-compose-material3-adaptive" } androidx-compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } From 81a8ebffe08a4aeda510d05fdcee397f3247015f Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 29 Mar 2024 16:04:17 -0400 Subject: [PATCH 063/143] Use Compose UI latest --- Jetcaster/app/build.gradle.kts | 1 + Jetcaster/gradle/libs.versions.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index a0c8428b9c..713bfc50f5 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive.layout) diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index 6b917df0d7..e3ff2372df 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -10,6 +10,7 @@ androidx-appcompat = "1.6.1" androidx-benchmark = "1.2.3" androidx-benchmark-junit4 = "1.2.3" androidx-compose-bom = "2024.03.00" +androidx-compose-latest = "1.7.0-alpha05" androidx-compose-material3-adaptive = "1.0.0-alpha09" androidx-compose-material3-latest = "1.3.0-alpha03" androidx-constraintlayout = "1.0.1" @@ -85,7 +86,6 @@ androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } -androidx-compose-material = { module = "androidx.compose.material:material" } androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3-latest" } androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidx-compose-material3-adaptive" } @@ -94,7 +94,7 @@ androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.mat androidx-compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } -androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-latest" } androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } From 49933d8d2b7ab62f25fd9cb80d7d72b41ecafb0c Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 29 Mar 2024 16:18:07 -0400 Subject: [PATCH 064/143] Add loading states. --- .../com/example/jetcaster/ui/home/Home.kt | 2 +- .../ui/podcast/PodcastDetailsScreen.kt | 35 ++++++++++++++----- .../ui/podcast/PodcastDetailsViewModel.kt | 15 ++++---- .../example/jetcaster/ui/shared/Loading.kt | 22 ++++++++++++ 4 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 6270eaa7c3..ea438d3232 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -133,7 +133,7 @@ fun Home( AnimatedPane { if (podcastUri.isNullOrEmpty()) { // TODO - Text(text = "") + Text(text = "Empty State") } else { val podcastDetailsViewModel = PodcastDetailsViewModel(podcastUri = podcastUri) PodcastDetailsScreen( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index 80808ab3c3..11f76c5845 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -69,6 +69,7 @@ import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.ui.shared.Loading import kotlinx.coroutines.launch @Composable @@ -79,15 +80,31 @@ fun PodcastDetailsScreen( modifier: Modifier = Modifier ) { val state by viewModel.state.collectAsStateWithLifecycle() - PodcastDetailsScreen( - podcast = state.podcast, - episodes = state.episodes, - toggleSubscribe = viewModel::toggleSusbcribe, - onQueueEpisode = viewModel::onQueueEpisode, - navigateToPlayer = navigateToPlayer, - navigateBack = navigateBack, - modifier = modifier, - ) + 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, + navigateToPlayer = navigateToPlayer, + navigateBack = navigateBack, + modifier = modifier, + ) + } + } +} + +@Composable +private fun PodcastDetailsLoadingScreen( + modifier: Modifier = Modifier +) { + Loading(modifier = modifier) } @Composable diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 6fa3546920..d4e10ac7a4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -38,10 +38,13 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -data class PodcastUiState( - val podcast: PodcastInfo = PodcastInfo(), - val episodes: List = emptyList() -) +sealed interface PodcastUiState { + data object Loading : PodcastUiState + data class Ready( + val podcast: PodcastInfo, + val episodes: List, + ) : PodcastUiState +} /** * ViewModel that handles the business logic and screen state of the Podcast details screen. @@ -71,14 +74,14 @@ class PodcastDetailsViewModel( episodeStore.episodesInPodcast(podcastUri) ) { podcast, episodeToPodcasts -> val episodes = episodeToPodcasts.map { it.episode.asExternalModel() } - PodcastUiState( + PodcastUiState.Ready( podcast = podcast.podcast.asExternalModel().copy(isSubscribed = podcast.isFollowed), episodes = episodes, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = PodcastUiState() + initialValue = PodcastUiState.Loading ) fun toggleSusbcribe(podcast: PodcastInfo) { diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt new file mode 100644 index 0000000000..5191173d79 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt @@ -0,0 +1,22 @@ +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) + ) + } + } +} From ae2f2e58ba76d1f1f1532ed58202189cdd170776 Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 1 Apr 2024 16:25:08 +0000 Subject: [PATCH 065/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/jetcaster/ui/home/Home.kt | 10 +++++----- .../com/example/jetcaster/ui/shared/Loading.kt | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index ea438d3232..0aabded9ae 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -103,12 +103,12 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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) @Composable @@ -128,8 +128,8 @@ fun Home( value = navigator.scaffoldValue, directive = navigator.scaffoldDirective, supportingPane = { - val podcastUri = navigator.currentDestination?.content ?: - viewState.featuredPodcasts.firstOrNull()?.uri + val podcastUri = navigator.currentDestination?.content + ?: viewState.featuredPodcasts.firstOrNull()?.uri AnimatedPane { if (podcastUri.isNullOrEmpty()) { // TODO diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt index 5191173d79..4b96dc6e8a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt @@ -1,3 +1,19 @@ +/* + * 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 From 2a65a175a9c4f037fa72bdabdbc9f3bdd879de3c Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 10:40:33 -0700 Subject: [PATCH 066/143] Use Surface as root of Home screen. --- .../com/example/jetcaster/ui/home/Home.kt | 101 +++++++++--------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 0aabded9ae..0de12625e4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -57,6 +57,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabPosition import androidx.compose.material3.TabRow @@ -103,12 +104,12 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable @@ -124,54 +125,56 @@ fun Home( BackHandler(enabled = navigator.canNavigateBack()) { navigator.navigateBack() } - SupportingPaneScaffold( - value = navigator.scaffoldValue, - directive = navigator.scaffoldDirective, - supportingPane = { - val podcastUri = navigator.currentDestination?.content - ?: viewState.featuredPodcasts.firstOrNull()?.uri - AnimatedPane { - if (podcastUri.isNullOrEmpty()) { - // TODO - Text(text = "Empty State") - } else { - val podcastDetailsViewModel = PodcastDetailsViewModel(podcastUri = podcastUri) - PodcastDetailsScreen( - viewModel = podcastDetailsViewModel, - navigateToPlayer = navigateToPlayer, - navigateBack = { - if (navigator.canNavigateBack()) { - navigator.navigateBack() + Surface { + SupportingPaneScaffold( + value = navigator.scaffoldValue, + directive = navigator.scaffoldDirective, + supportingPane = { + val podcastUri = navigator.currentDestination?.content + ?: viewState.featuredPodcasts.firstOrNull()?.uri + AnimatedPane { + if (podcastUri.isNullOrEmpty()) { + // TODO + Text(text = "Empty State") + } else { + val podcastDetailsViewModel = PodcastDetailsViewModel(podcastUri = podcastUri) + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = navigateToPlayer, + navigateBack = { + if (navigator.canNavigateBack()) { + navigator.navigateBack() + } } - } - ) + ) + } } - } - }, - mainPane = { - Home( - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - filterableCategoriesModel = viewState.filterableCategoriesModel, - podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, - library = viewState.library, - onHomeCategorySelected = viewModel::onHomeCategorySelected, - onCategorySelected = viewModel::onCategorySelected, - onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - navigateToPodcastDetails = { - navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri) - }, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, - onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, - onQueueEpisode = viewModel::onQueueEpisode, - modifier = Modifier.fillMaxSize() - ) - }, - modifier = Modifier.fillMaxSize() - ) + }, + mainPane = { + Home( + featuredPodcasts = viewState.featuredPodcasts, + isRefreshing = viewState.refreshing, + homeCategories = viewState.homeCategories, + selectedHomeCategory = viewState.selectedHomeCategory, + filterableCategoriesModel = viewState.filterableCategoriesModel, + podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, + library = viewState.library, + onHomeCategorySelected = viewModel::onHomeCategorySelected, + onCategorySelected = viewModel::onCategorySelected, + onPodcastUnfollowed = viewModel::onPodcastUnfollowed, + navigateToPodcastDetails = { + navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri) + }, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, + onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, + onQueueEpisode = viewModel::onQueueEpisode, + modifier = Modifier.fillMaxSize() + ) + }, + modifier = Modifier.fillMaxSize() + ) + } } @OptIn(ExperimentalMaterial3Api::class) From 40a6b47571c506643237660fb8a7b28870fbfc76 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 10:03:34 -0700 Subject: [PATCH 067/143] Fix tests. --- .../domain/FilterableCategoriesUseCaseTest.kt | 2 +- .../PodcastCategoryFilterUseCaseTest.kt | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt index 539415fe38..13b5cb4533 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt @@ -58,7 +58,7 @@ class FilterableCategoriesUseCaseTest { val selectedCategory = testCategories[2] val filterableCategories = useCase(selectedCategory.asExternalModel()).first() assertEquals( - selectedCategory, + selectedCategory.asExternalModel(), filterableCategories.selectedCategory ) } diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index 6c903269f3..38839532c1 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -22,13 +22,14 @@ 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.model.asExternalModel +import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore -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 +import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { @@ -41,6 +42,12 @@ class PodcastCategoryFilterUseCaseTest { "Episode 1", published = OffsetDateTime.now() ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 1" + ) + ) }, EpisodeToPodcast().apply { episode = Episode( @@ -49,14 +56,26 @@ class PodcastCategoryFilterUseCaseTest { "Episode 2", published = OffsetDateTime.now() ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 2" + ) + ) }, EpisodeToPodcast().apply { episode = Episode( "", "", - "Episode 2", + "Episode 3", published = OffsetDateTime.now() ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 3" + ) + ) } ) private val testCategory = Category(1, "Technology") @@ -86,11 +105,11 @@ class PodcastCategoryFilterUseCaseTest { val result = resultFlow.first() assertEquals( - testPodcasts, + testPodcasts.map { it.asExternalModel() }, result.topPodcasts ) assertEquals( - testEpisodeToPodcast, + testEpisodeToPodcast.map { it.asPodcastCategoryEpisode() }, result.episodes ) } From 27c8ea682e5b1827aa4885d6cb5c0318a7ea9f01 Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 1 Apr 2024 17:52:13 +0000 Subject: [PATCH 068/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/domain/PodcastCategoryFilterUseCaseTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index 38839532c1..bb20c1915b 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -24,12 +24,12 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore +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 -import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { From 9215a15d385322e7690fcceb070011ac030ff35e Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 12:24:45 -0700 Subject: [PATCH 069/143] Remove TODO. --- .../src/main/java/com/example/jetcaster/ui/home/Home.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 0de12625e4..20403a8e14 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -133,11 +133,10 @@ fun Home( val podcastUri = navigator.currentDestination?.content ?: viewState.featuredPodcasts.firstOrNull()?.uri AnimatedPane { - if (podcastUri.isNullOrEmpty()) { - // TODO - Text(text = "Empty State") - } else { - val podcastDetailsViewModel = PodcastDetailsViewModel(podcastUri = podcastUri) + if (!podcastUri.isNullOrEmpty()) { + val podcastDetailsViewModel = PodcastDetailsViewModel( + podcastUri = podcastUri + ) PodcastDetailsScreen( viewModel = podcastDetailsViewModel, navigateToPlayer = navigateToPlayer, From 2a44a81f010b2a2e1c907f65b4899254f3cd1392 Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 1 Apr 2024 19:28:34 +0000 Subject: [PATCH 070/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 20403a8e14..a95c850d59 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -104,12 +104,12 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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) @Composable From 39d750b84dd633615f3a21bab12c163f8d9dcf85 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 13:35:10 -0700 Subject: [PATCH 071/143] Use Rows and Columns for EpisodeListItem to avoid crash. --- .../com/example/jetcaster/ui/home/Home.kt | 27 +- .../jetcaster/ui/home/library/Library.kt | 1 - .../jetcaster/ui/shared/EpisodeListItem.kt | 237 ++++++++++-------- 3 files changed, 146 insertions(+), 119 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index a95c850d59..efdb76cea4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -65,7 +65,6 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator @@ -132,21 +131,19 @@ fun Home( supportingPane = { val podcastUri = navigator.currentDestination?.content ?: viewState.featuredPodcasts.firstOrNull()?.uri - AnimatedPane { - if (!podcastUri.isNullOrEmpty()) { - val podcastDetailsViewModel = PodcastDetailsViewModel( - podcastUri = podcastUri - ) - PodcastDetailsScreen( - viewModel = podcastDetailsViewModel, - navigateToPlayer = navigateToPlayer, - navigateBack = { - if (navigator.canNavigateBack()) { - navigator.navigateBack() - } + if (!podcastUri.isNullOrEmpty()) { + val podcastDetailsViewModel = PodcastDetailsViewModel( + podcastUri = podcastUri + ) + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = navigateToPlayer, + navigateBack = { + if (navigator.canNavigateBack()) { + navigator.navigateBack() } - ) - } + } + ) } }, mainPane = { diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index afb2c26108..31f04698fb 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -63,7 +63,6 @@ fun LazyListScope.libraryItems( onClick = navigateToPlayer, onQueueEpisode = onQueueEpisode, modifier = Modifier.fillParentMaxWidth(), - showDivider = index != 0 ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index c2a8ab6225..015b198b6c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -16,44 +16,51 @@ package com.example.jetcaster.ui.shared +import android.content.res.Configuration import androidx.compose.foundation.Image +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.Row 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.MoreVert import androidx.compose.material.icons.rounded.PlayCircleFilled -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode 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 androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension -import androidx.constraintlayout.compose.Visibility import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R import com.example.jetcaster.core.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.home.PreviewEpisodes +import com.example.jetcaster.ui.home.PreviewPodcasts +import com.example.jetcaster.ui.theme.JetcasterTheme import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -64,87 +71,50 @@ fun EpisodeListItem( onClick: (EpisodeInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, - showDivider: Boolean = true, showPodcastImage: Boolean = true, ) { - ConstraintLayout( - modifier = modifier.clickable { - onClick(episode) - } - ) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() - - if (showDivider) { - HorizontalDivider( - Modifier.constrainAs(divider) { - top.linkTo(parent.top) - centerHorizontallyTo(parent) - width = Dimension.fillToConstraints - } - ) - } - - // If we have an image Url, we can show it using Coil - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcast.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.medium) - .constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - visibility = if (showPodcastImage) Visibility.Visible else Visibility.Gone - }, - ) - - Text( - text = episode.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.constrainAs(episodeTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f + Box(modifier = modifier.padding(vertical = 8.dp, horizontal = 16.dp)) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { + onClick(episode) + }, + ) { + // Top Part + EpisodeListItemHeader( + episode = episode, + podcast = podcast, + showPodcastImage = showPodcastImage, + modifier = Modifier.padding(bottom = 4.dp) ) - top.linkTo(parent.top, 16.dp) - height = Dimension.preferredWrapContent - width = Dimension.preferredWrapContent - } - ) - val titleImageBarrier = createBottomBarrier(podcastTitle, image) - - Text( - text = podcast.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.constrainAs(podcastTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f + // Bottom Part + EpisodeListItemFooter( + episode = episode, + podcast = podcast, + onQueueEpisode = onQueueEpisode, ) - top.linkTo(episodeTitle.bottom, 6.dp) - height = Dimension.preferredWrapContent - width = Dimension.preferredWrapContent } - ) + } + } +} +@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), @@ -158,11 +128,6 @@ fun EpisodeListItem( .size(48.dp) .padding(6.dp) .semantics { role = Role.Button } - .constrainAs(playIcon) { - start.linkTo(parent.start, Keyline1) - top.linkTo(titleImageBarrier, margin = 10.dp) - bottom.linkTo(parent.bottom, 10.dp) - } ) val duration = episode.duration @@ -183,17 +148,9 @@ fun EpisodeListItem( maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, - modifier = Modifier.constrainAs(date) { - centerVerticallyTo(playIcon) - linkTo( - start = playIcon.end, - startMargin = 12.dp, - end = addPlaylist.start, - endMargin = 16.dp, - bias = 0f // float this towards the start - ) - width = Dimension.preferredWrapContent - } + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) ) IconButton( @@ -205,10 +162,6 @@ fun EpisodeListItem( ) ) }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } ) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, @@ -219,10 +172,6 @@ fun EpisodeListItem( IconButton( onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(overflow) { - end.linkTo(parent.end, 8.dp) - centerVerticallyTo(playIcon) - } ) { Icon( imageVector = Icons.Default.MoreVert, @@ -233,6 +182,88 @@ fun EpisodeListItem( } } +@Composable +fun EpisodeListItemHeader( + episode: EpisodeInfo, + podcast: PodcastInfo, + showPodcastImage: Boolean, + modifier: Modifier = Modifier +) { + Row(modifier = modifier) { + Column( + modifier = + Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + Text( + text = episode.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 6.dp) + ) + + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + ) + } + if (showPodcastImage) { + EpisodeListItemImage( + podcast = podcast, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + ) + } + } +} + +@Composable +private fun EpisodeListItemImage( + podcast: PodcastInfo, + modifier: Modifier = Modifier +) { + if (LocalInspectionMode.current) { + Box(modifier = modifier.background(MaterialTheme.colorScheme.primary)) + } else { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcast.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + ) + } +} + +@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 = {} + ) + } +} + private val MediumDateFormatter by lazy { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } From afe8af7f7d4827e923768372fc4c5b681f0cb7a9 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 13:38:25 -0700 Subject: [PATCH 072/143] PR feedback. --- .../src/main/java/com/example/jetcaster/ui/JetcasterApp.kt | 3 --- .../src/main/java/com/example/jetcaster/ui/home/Home.kt | 7 +++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index facd39186a..23699c40d6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -56,9 +56,6 @@ fun JetcasterApp( ) { composable(Screen.Home.route) { backStackEntry -> Home( - navigateToPodcastDetails = { podcast -> - appState.navigateToPodcastDetails(podcast.uri, backStackEntry) - }, navigateToPlayer = { episode -> appState.navigateToPlayer(episode.uri, backStackEntry) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index efdb76cea4..b833339153 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -103,17 +103,16 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun Home( - navigateToPodcastDetails: (PodcastInfo) -> Unit, navigateToPlayer: (EpisodeInfo) -> Unit, viewModel: HomeViewModel = viewModel() ) { From c89178ccd45105cc9851b4996eae7f2486748d8e Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 1 Apr 2024 20:40:58 +0000 Subject: [PATCH 073/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index b833339153..948d3c2118 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -103,12 +103,12 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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) @Composable From 50f76620113562d8de71f431d9b78aa472a8828d Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 2 Apr 2024 12:54:15 +0900 Subject: [PATCH 074/143] Fix a build error due to resolution failure of BasicTextField2 --- .../java/com/example/jetcaster/tv/ui/search/SearchScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt index 67295c7537..dd05155161 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -27,8 +27,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text2.BasicTextField2 import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable @@ -191,14 +191,14 @@ private fun KeywordInput( color = MaterialTheme.colorScheme.onSurfaceVariant ) val cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant) - BasicTextField2( + BasicTextField( value = keyword, onValueChange = onKeywordInput, textStyle = textStyle, cursorBrush = cursorBrush, modifier = modifier, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), - decorator = { innerTextField -> + decorationBox = { innerTextField -> Box( modifier = Modifier .fillMaxWidth() From ba16ae82bc9dd6dd84b43ae75adf6f2b9cf893fd Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 2 Apr 2024 12:54:50 +0900 Subject: [PATCH 075/143] Fix layout glitch due to line break character in the category name --- .../tv/ui/discover/DiscoverScreenViewModel.kt | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 911b92eb03..63520a809f 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -16,14 +16,11 @@ package com.example.jetcaster.tv.ui.discover -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.di.Graph 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.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList @@ -41,15 +38,25 @@ import kotlinx.coroutines.launch class DiscoverScreenViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, private val categoryStore: CategoryStore = Graph.categoryStore, - private val podcastStore: PodcastStore = Graph.podcastStore, ) : ViewModel() { private val _selectedCategory = MutableStateFlow(null) + + private val categoryListFlow = categoryStore + .categoriesSortedByPodcastCount() + .map { categoryList -> + categoryList.map { category -> + Category( + id = category.id, + name = category.name.filter { !it.isWhitespace() } + ) + } + } + private val selectedCategoryFlow = combine( - categoryStore.categoriesSortedByPodcastCount(), + categoryListFlow, _selectedCategory ) { categoryList, category -> - Log.d("category list", "$categoryList") category ?: categoryList.firstOrNull() } @@ -75,8 +82,9 @@ class DiscoverScreenViewModel( EpisodeList(it) } + val uiState = combine( - categoryStore.categoriesSortedByPodcastCount(), + categoryListFlow, selectedCategoryFlow, podcastInSelectedCategory, latestEpisodeFlow, @@ -105,14 +113,6 @@ class DiscoverScreenViewModel( _selectedCategory.value = category } - fun subscribe(podcastWithExtraInfo: PodcastWithExtraInfo) { - if (!podcastWithExtraInfo.isFollowed) { - viewModelScope.launch { - podcastStore.togglePodcastFollowed(podcastWithExtraInfo.podcast.uri) - } - } - } - private fun refresh() { viewModelScope.launch { podcastsRepository.updatePodcasts(false) From a6df1a146423714784e1a470fe1dad9c30073690 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 2 Apr 2024 12:56:24 +0900 Subject: [PATCH 076/143] Coead cleanup --- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index 6f3f21d396..9c33abfb4e 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -32,7 +32,6 @@ 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 @@ -263,33 +262,29 @@ private fun EpisodeListItem( modifier: Modifier = Modifier, selected: Boolean = false ) { + val duration = episodeToPodcast.episode.duration + ListItem( selected = selected, onClick = { onInfoClicked(episodeToPodcast) }, onLongClick = { onEpisodeSelected(episodeToPodcast) }, + supportingContent = { + if (duration != null) { + EpisodeDataAndDuration(episodeToPodcast.episode.published, duration) + } + }, modifier = modifier ) { - Row( - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - EpisodeMetaData(episode = episodeToPodcast.episode) - } + EpisodeTitle(episode = episodeToPodcast.episode) } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun EpisodeMetaData(episode: Episode, modifier: Modifier = Modifier) { - val published = episode.published - val duration = episode.duration - Column(modifier = modifier) { - Text( - text = episode.title, - style = MaterialTheme.typography.bodyMedium - ) - if (duration != null) { - EpisodeDataAndDuration(published, duration) - } - } +private fun EpisodeTitle(episode: Episode, modifier: Modifier = Modifier) { + Text( + text = episode.title, + style = MaterialTheme.typography.bodyMedium, + modifier = modifier + ) } From c8b38f888683db016af237458fea9e0cbe4ae678 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 2 Apr 2024 13:08:41 +0900 Subject: [PATCH 077/143] Format the code --- .../example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 63520a809f..3947036353 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -82,7 +82,6 @@ class DiscoverScreenViewModel( EpisodeList(it) } - val uiState = combine( categoryListFlow, selectedCategoryFlow, From caf0dbe8e85327eaa59dc9281c5749b325cf8b43 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 12:23:30 -0700 Subject: [PATCH 078/143] UI Polish. --- .../com/example/jetcaster/ui/JetcasterApp.kt | 5 +- .../com/example/jetcaster/ui/home/Home.kt | 335 ++++++++++++++---- .../example/jetcaster/ui/home/PreviewData.kt | 2 +- .../ui/home/category/PodcastCategory.kt | 30 ++ .../jetcaster/ui/home/discover/Discover.kt | 37 ++ .../jetcaster/ui/home/library/Library.kt | 48 ++- .../ui/podcast/PodcastDetailsScreen.kt | 14 +- .../jetcaster/ui/shared/EpisodeListItem.kt | 4 +- .../jetcaster/util/LazyVerticalGrid.kt | 20 ++ .../example/jetcaster/util/WindowSizeClass.kt | 12 + 10 files changed, 425 insertions(+), 82 deletions(-) create mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt create mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 23699c40d6..d028717c35 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -37,7 +37,7 @@ import com.example.jetcaster.R import com.example.jetcaster.core.data.di.Graph.episodePlayer import com.example.jetcaster.core.data.di.Graph.episodeStore import com.example.jetcaster.core.data.di.Graph.podcastStore -import com.example.jetcaster.ui.home.Home +import com.example.jetcaster.ui.home.MainScreen import com.example.jetcaster.ui.player.PlayerScreen import com.example.jetcaster.ui.player.PlayerViewModel import com.example.jetcaster.ui.podcast.PodcastDetailsScreen @@ -55,7 +55,8 @@ fun JetcasterApp( startDestination = Screen.Home.route ) { composable(Screen.Home.route) { backStackEntry -> - Home( + MainScreen( + windowSizeClass = windowSizeClass, navigateToPlayer = { episode -> appState.navigateToPlayer(episode.uri, backStackEntry) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 948d3c2118..143486ee66 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -20,7 +20,6 @@ package com.example.jetcaster.ui.home import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -42,6 +40,8 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.PagerState @@ -51,10 +51,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -68,6 +69,8 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -79,10 +82,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -101,18 +105,20 @@ 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.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.verticalGradientScrim -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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun Home( +fun MainScreen( + windowSizeClass: WindowSizeClass, navigateToPlayer: (EpisodeInfo) -> Unit, viewModel: HomeViewModel = viewModel() ) { @@ -146,7 +152,8 @@ fun Home( } }, mainPane = { - Home( + HomeScreen( + windowSizeClass = windowSizeClass, featuredPodcasts = viewState.featuredPodcasts, isRefreshing = viewState.refreshing, homeCategories = viewState.homeCategories, @@ -174,50 +181,58 @@ fun Home( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeAppBar( - backgroundColor: Color, - modifier: Modifier = Modifier +private fun HomeAppBar( + selectedHomeCategory: HomeCategory, + homeCategories: List, + onHomeCategorySelected: (HomeCategory) -> Unit, + modifier: Modifier = Modifier, + showHomeCategoryToggle: Boolean = false, ) { TopAppBar( title = { - Row { - Image( - painter = painterResource(R.drawable.ic_logo), - contentDescription = null - ) - Icon( - painter = painterResource(R.drawable.ic_text_logo), - contentDescription = stringResource(R.string.app_name), - modifier = Modifier - .padding(start = 4.dp) - .heightIn(max = 24.dp) - ) - } - }, - actions = { - IconButton( - onClick = { /* TODO: Open search */ } - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.cd_search) - ) - } - IconButton( - onClick = { /* TODO: Open account? */ } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 16.dp) ) { - Icon( - imageVector = Icons.Default.AccountCircle, - contentDescription = stringResource(R.string.cd_account) - ) + if (showHomeCategoryToggle) { + HomeCategoryTabs( + categories = homeCategories, + selectedCategory = selectedHomeCategory, + onCategorySelected = onHomeCategorySelected, + modifier = Modifier.width(240.dp), + showHorizontalLine = false + ) + Spacer(modifier = Modifier.weight(1f)) + } + SearchBar( + query = "Jetcaster", + onQueryChange = {}, + onSearch = {}, + active = false, + onActiveChange = {}, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = stringResource(R.string.cd_account) + ) + }, + ) { } } }, - modifier = modifier.background(backgroundColor) + modifier = modifier.padding(vertical = 8.dp) ) } @Composable -fun Home( +private fun HomeScreen( + windowSizeClass: WindowSizeClass, featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, @@ -250,8 +265,11 @@ fun Home( ), topBar = { HomeAppBar( - backgroundColor = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth() + selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, + onHomeCategorySelected = onHomeCategorySelected, + showHomeCategoryToggle = !windowSizeClass.isCompact, + modifier = Modifier.fillMaxWidth(), ) }, snackbarHost = { @@ -259,9 +277,9 @@ fun Home( } ) { contentPadding -> // Main Content - val scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) HomeContent( + showGrid = !windowSizeClass.isCompact, featuredPodcasts = featuredPodcasts, isRefreshing = isRefreshing, selectedHomeCategory = selectedHomeCategory, @@ -269,7 +287,6 @@ fun Home( filterableCategoriesModel = filterableCategoriesModel, podcastCategoryFilterResult = podcastCategoryFilterResult, library = library, - scrimColor = scrimColor, modifier = Modifier.padding(contentPadding), onPodcastUnfollowed = onPodcastUnfollowed, onHomeCategorySelected = onHomeCategorySelected, @@ -288,9 +305,9 @@ fun Home( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeContent( + showGrid: Boolean, featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, @@ -298,7 +315,6 @@ private fun HomeContent( filterableCategoriesModel: FilterableCategoriesModel, podcastCategoryFilterResult: PodcastCategoryFilterResult, library: LibraryInfo, - scrimColor: Color, modifier: Modifier = Modifier, onPodcastUnfollowed: (PodcastInfo) -> Unit, onHomeCategorySelected: (HomeCategory) -> Unit, @@ -317,6 +333,70 @@ private fun HomeContent( onLibraryPodcastSelected(podcast) } } + + // Note: ideally, `HomeContentColumn` and `HomeContentGrid` would be the same implementation + // (i.e. a grid). However, LazyVerticalGrid does not have the concept of a sticky header. + // So we are using two different composables here depending on the provided window size class. + // See: https://issuetracker.google.com/issues/231557184 + if (showGrid) { + HomeContentGrid( + pagerState = pagerState, + featuredPodcasts = featuredPodcasts, + isRefreshing = isRefreshing, + selectedHomeCategory = selectedHomeCategory, + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = library, + modifier = modifier, + onPodcastUnfollowed = onPodcastUnfollowed, + onCategorySelected = onCategorySelected, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, + ) + } else { + HomeContentColumn( + pagerState = pagerState, + featuredPodcasts = featuredPodcasts, + isRefreshing = isRefreshing, + selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = library, + modifier = modifier, + onPodcastUnfollowed = onPodcastUnfollowed, + onHomeCategorySelected = onHomeCategorySelected, + onCategorySelected = onCategorySelected, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HomeContentColumn( + pagerState: PagerState, + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + homeCategories: List, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + modifier: Modifier = Modifier, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + onHomeCategorySelected: (HomeCategory) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, +) { LazyColumn(modifier = modifier.fillMaxSize()) { if (featuredPodcasts.isNotEmpty()) { item { @@ -327,11 +407,6 @@ private fun HomeContent( navigateToPodcastDetails = navigateToPodcastDetails, modifier = Modifier .fillMaxWidth() - .verticalGradientScrim( - color = scrimColor, - startYPercentage = 1f, - endYPercentage = 0f - ) ) } } @@ -374,6 +449,68 @@ private fun HomeContent( } } +@Composable +private fun HomeContentGrid( + pagerState: PagerState, + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + modifier: Modifier = Modifier, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(362.dp), + modifier = modifier.fillMaxSize() + ) { + if (featuredPodcasts.isNotEmpty()) { + fullWidthItem { + FollowedPodcastItem( + pagerState = pagerState, + items = featuredPodcasts, + onPodcastUnfollowed = onPodcastUnfollowed, + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier + .fillMaxWidth() + ) + } + } + + if (isRefreshing) { + // TODO show a progress indicator or similar + } + + when (selectedHomeCategory) { + HomeCategory.Library -> { + libraryItems( + library = library, + navigateToPlayer = navigateToPlayer, + onQueueEpisode = onQueueEpisode + ) + } + + HomeCategory.Discover -> { + discoverItems( + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onCategorySelected = onCategorySelected, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode + ) + } + } + } +} + @Composable private fun FollowedPodcastItem( pagerState: PagerState, @@ -402,8 +539,13 @@ private fun HomeCategoryTabs( categories: List, selectedCategory: HomeCategory, onCategorySelected: (HomeCategory) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showHorizontalLine: Boolean = true, ) { + if (categories.isEmpty()) { + return + } + val selectedIndex = categories.indexOfFirst { it == selectedCategory } val indicator = @Composable { tabPositions: List -> HomeCategoryTabIndicator( @@ -414,7 +556,12 @@ private fun HomeCategoryTabs( TabRow( selectedTabIndex = selectedIndex, indicator = indicator, - modifier = modifier + modifier = modifier, + divider = { + if (showHorizontalLine) { + HorizontalDivider() + } + } ) { categories.forEachIndexed { index, category -> Tab( @@ -435,7 +582,7 @@ private fun HomeCategoryTabs( } @Composable -fun HomeCategoryTabIndicator( +private fun HomeCategoryTabIndicator( modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.onSurface ) { @@ -447,12 +594,10 @@ fun HomeCategoryTabIndicator( ) } -private val FEATURED_PODCAST_IMAGE_WIDTH_DP = 160.dp -private val FEATURED_PODCAST_IMAGE_HEIGHT_DP = 180.dp +private val FEATURED_PODCAST_IMAGE_SIZE_DP = 160.dp -@OptIn(ExperimentalFoundationApi::class) @Composable -fun FollowedPodcasts( +private fun FollowedPodcasts( pagerState: PagerState, items: PersistentList, onPodcastUnfollowed: (PodcastInfo) -> Unit, @@ -465,7 +610,7 @@ fun FollowedPodcasts( // which solves this problem and avoids this calculation altogether. Once 1.7.0 is // stable, this implementation can be updated. BoxWithConstraints(modifier) { - val horizontalPadding = (this.maxWidth - FEATURED_PODCAST_IMAGE_WIDTH_DP) / 2 + val horizontalPadding = (this.maxWidth - FEATURED_PODCAST_IMAGE_SIZE_DP) / 2 HorizontalPager( state = pagerState, contentPadding = PaddingValues( @@ -473,7 +618,7 @@ fun FollowedPodcasts( vertical = 16.dp, ), pageSpacing = 24.dp, - pageSize = PageSize.Fixed(FEATURED_PODCAST_IMAGE_WIDTH_DP) + pageSize = PageSize.Fixed(FEATURED_PODCAST_IMAGE_SIZE_DP) ) { page -> val podcast = items[page] FollowedPodcastCarouselItem( @@ -502,8 +647,7 @@ private fun FollowedPodcastCarouselItem( Column(modifier) { Box( Modifier - .height(FEATURED_PODCAST_IMAGE_HEIGHT_DP) - .width(FEATURED_PODCAST_IMAGE_WIDTH_DP) + .size(FEATURED_PODCAST_IMAGE_SIZE_DP) .align(Alignment.CenterHorizontally) ) { if (podcastImageUrl != null) { @@ -555,11 +699,62 @@ private fun lastUpdated(updated: OffsetDateTime): String { } } -@Composable @Preview -fun PreviewHomeContent() { +@Composable +private fun HomeAppBarPreview() { + JetcasterTheme { + HomeAppBar( + homeCategories = emptyList(), + onHomeCategorySelected = {}, + selectedHomeCategory = HomeCategory.Discover, + ) + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +private val CompactWindowSizeClass = WindowSizeClass.calculateFromSize( + size = DpSize(width = 360.dp, height = 780.dp) +) + +@Preview(device = Devices.PHONE) +@Composable +private fun PreviewHomeContent() { + JetcasterTheme { + HomeScreen( + windowSizeClass = CompactWindowSizeClass, + featuredPodcasts = PreviewPodcasts.toPersistentList(), + isRefreshing = false, + homeCategories = HomeCategory.entries, + selectedHomeCategory = HomeCategory.Discover, + filterableCategoriesModel = FilterableCategoriesModel( + categories = PreviewCategories, + selectedCategory = PreviewCategories.firstOrNull() + ), + podcastCategoryFilterResult = PodcastCategoryFilterResult( + topPodcasts = PreviewPodcasts, + episodes = PreviewPodcastCategoryEpisodes + ), + library = LibraryInfo(), + onCategorySelected = {}, + onPodcastUnfollowed = {}, + navigateToPodcastDetails = {}, + navigateToPlayer = {}, + onHomeCategorySelected = {}, + onTogglePodcastFollowed = {}, + onLibraryPodcastSelected = {}, + onQueueEpisode = {} + ) + } +} + +@Preview(device = Devices.FOLDABLE) +@Preview(device = Devices.TABLET) +@Preview(device = Devices.DESKTOP) +@Composable +private fun PreviewHomeContentExpanded() { JetcasterTheme { - Home( + HomeScreen( + windowSizeClass = CompactWindowSizeClass, featuredPodcasts = PreviewPodcasts.toPersistentList(), isRefreshing = false, homeCategories = HomeCategory.entries, @@ -587,7 +782,7 @@ fun PreviewHomeContent() { @Composable @Preview -fun PreviewPodcastCard() { +private fun PreviewPodcastCard() { JetcasterTheme { FollowedPodcastCarouselItem( modifier = Modifier.size(128.dp), diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index fb34b901fd..5030d56866 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -48,7 +48,7 @@ val PreviewPodcasts = listOf( val PreviewEpisodes = listOf( EpisodeInfo( uri = "fakeUri://episode/1", - title = "Episode 140: Bubbles!", + 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( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index a7885e5318..857a5572d1 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme @@ -54,6 +56,7 @@ import com.example.jetcaster.ui.home.PreviewPodcasts 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 LazyListScope.podcastCategory( podcastCategoryFilterResult: PodcastCategoryFilterResult, @@ -82,6 +85,33 @@ fun LazyListScope.podcastCategory( } } +fun LazyGridScope.podcastCategory( + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, +) { + fullWidthItem { + CategoryPodcasts( + topPodcasts = podcastCategoryFilterResult.topPodcasts, + navigateToPodcastDetails = navigateToPodcastDetails, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) + } + + val episodes = podcastCategoryFilterResult.episodes + items(episodes, key = { it.episode.uri }) { item -> + EpisodeListItem( + episode = item.episode, + podcast = item.podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillMaxWidth() + ) + } +} + @Composable private fun CategoryPodcasts( topPodcasts: List, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 7a5632c3c5..835fb03441 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon @@ -45,6 +46,7 @@ import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.PodcastInfo import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.category.podcastCategory +import com.example.jetcaster.util.fullWidthItem fun LazyListScope.discoverItems( filterableCategoriesModel: FilterableCategoriesModel, @@ -81,6 +83,41 @@ fun LazyListScope.discoverItems( ) } +fun LazyGridScope.discoverItems( + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (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, + ) +} + private val emptyTabIndicator: @Composable (List) -> Unit = {} @Composable diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index 31f04698fb..d33934376b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -16,9 +16,12 @@ package com.example.jetcaster.ui.home.library +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Modifier @@ -30,6 +33,7 @@ import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.util.fullWidthItem fun LazyListScope.libraryItems( library: LibraryInfo, @@ -53,10 +57,10 @@ fun LazyListScope.libraryItems( ) } - itemsIndexed( + items( library.episodes, - key = { _, item -> item.uri } - ) { index, item -> + key = { it.uri } + ) { item -> EpisodeListItem( episode = item, podcast = podcast, @@ -66,3 +70,39 @@ fun LazyListScope.libraryItems( ) } } + +fun LazyGridScope.libraryItems( + library: LibraryInfo, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit +) { + val podcast = library.podcast + if (podcast == null || library.episodes.isEmpty()) { + // TODO: Empty state + return + } + + fullWidthItem { + Text( + text = stringResource(id = R.string.latest_episodes), + modifier = Modifier.padding( + start = Keyline1, + top = 16.dp, + ), + style = MaterialTheme.typography.headlineLarge, + ) + } + + items( + library.episodes, + key = { it.uri } + ) { item -> + EpisodeListItem( + episode = item, + podcast = podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index 11f76c5845..066bf44e72 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -21,12 +21,14 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -70,6 +72,7 @@ import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.shared.EpisodeListItem import com.example.jetcaster.ui.shared.Loading +import com.example.jetcaster.util.fullWidthItem import kotlinx.coroutines.launch @Composable @@ -157,10 +160,11 @@ fun PodcastDetailsContent( navigateToPlayer: (EpisodeInfo) -> Unit, modifier: Modifier = Modifier ) { - LazyColumn( + LazyVerticalGrid( + columns = GridCells.Adaptive(362.dp), modifier.fillMaxSize() ) { - item { + fullWidthItem { PodcastDetailsHeaderItem( podcast = podcast, toggleSubscribe = toggleSubscribe, @@ -296,6 +300,8 @@ fun PodcastDetailsHeaderItemButtons( ) } + Spacer(modifier = Modifier.weight(1f)) + IconButton( onClick = { /* TODO */ }, modifier = Modifier.padding(start = 8.dp) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index 015b198b6c..44f6217b9f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -199,14 +199,16 @@ fun EpisodeListItemHeader( Text( text = episode.title, maxLines = 2, + minLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 6.dp) + modifier = Modifier.padding(vertical = 8.dp) ) Text( text = podcast.title, maxLines = 2, + minLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall, ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt new file mode 100644 index 0000000000..7dc254c21f --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt @@ -0,0 +1,20 @@ +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 +) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt new file mode 100644 index 0000000000..15094207a2 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt @@ -0,0 +1,12 @@ +package com.example.jetcaster.util + +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass + +/** + * Returns true if the width or height size classes are compact. + */ +val WindowSizeClass.isCompact: Boolean + get() = widthSizeClass == WindowWidthSizeClass.Compact || + heightSizeClass == WindowHeightSizeClass.Compact From 0b132338d0e63c1abddc1e59b6f998038ba33bca Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 1 Apr 2024 22:54:41 +0000 Subject: [PATCH 079/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- .../example/jetcaster/util/LazyVerticalGrid.kt | 16 ++++++++++++++++ .../example/jetcaster/util/WindowSizeClass.kt | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 143486ee66..28aeab2b2d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -108,12 +108,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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) @Composable diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt index 7dc254c21f..6233653f67 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt index 15094207a2..a115739d2b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt @@ -1,3 +1,19 @@ +/* + * 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.material3.windowsizeclass.WindowHeightSizeClass From 4a9ab77a7b846ac0443170eb3e839602c2cd9226 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 1 Apr 2024 18:21:45 -0700 Subject: [PATCH 080/143] Add placeholder text for search bar. --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 5 ++++- Jetcaster/app/src/main/res/values/strings.xml | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 28aeab2b2d..54865856f6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -206,8 +206,11 @@ private fun HomeAppBar( Spacer(modifier = Modifier.weight(1f)) } SearchBar( - query = "Jetcaster", + query = "", onQueryChange = {}, + placeholder = { + Text(stringResource(id = R.string.search_for_a_podcast)) + }, onSearch = {}, active = false, onActiveChange = {}, diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index c14861f017..793d511189 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -61,5 +61,6 @@ Subscribe Unsubscribe see more + Search for a podcast From 64ee70cbed5d0bc00d5b52cf8d53b47aa8602980 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 2 Apr 2024 11:50:42 -0700 Subject: [PATCH 081/143] [Jetcaster]: Provide PodcastDetailsViewModel via factory. --- .../com/example/jetcaster/ui/home/Home.kt | 23 ++++++++++++------- .../ui/podcast/PodcastDetailsViewModel.kt | 19 ++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 54865856f6..0d239bbc9c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -82,12 +82,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSavedStateRegistryOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -99,6 +101,7 @@ import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.ui.Screen import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.home.library.libraryItems import com.example.jetcaster.ui.podcast.PodcastDetailsScreen @@ -108,12 +111,12 @@ 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 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable @@ -123,9 +126,7 @@ fun MainScreen( viewModel: HomeViewModel = viewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() - val navigator = rememberSupportingPaneScaffoldNavigator( - isDestinationHistoryAware = false - ) + val navigator = rememberSupportingPaneScaffoldNavigator() BackHandler(enabled = navigator.canNavigateBack()) { navigator.navigateBack() } @@ -137,8 +138,14 @@ fun MainScreen( val podcastUri = navigator.currentDestination?.content ?: viewState.featuredPodcasts.firstOrNull()?.uri if (!podcastUri.isNullOrEmpty()) { - val podcastDetailsViewModel = PodcastDetailsViewModel( - podcastUri = podcastUri + val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( + key = podcastUri, + factory = PodcastDetailsViewModel.provideFactory( + owner = LocalSavedStateRegistryOwner.current, + defaultArgs = bundleOf( + Screen.ARG_PODCAST_URI to podcastUri + ) + ) ) PodcastDetailsScreen( viewModel = podcastDetailsViewModel, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index d4e10ac7a4..36de9083d2 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -18,6 +18,7 @@ package com.example.jetcaster.ui.podcast import android.net.Uri import android.os.Bundle +import android.util.Log import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -53,20 +54,14 @@ class PodcastDetailsViewModel( private val episodeStore: EpisodeStore = Graph.episodeStore, private val episodePlayer: EpisodePlayer = Graph.episodePlayer, private val podcastStore: PodcastStore = Graph.podcastStore, - private val podcastUri: String + savedStateHandle: SavedStateHandle ) : ViewModel() { - constructor( - episodeStore: EpisodeStore = Graph.episodeStore, - episodePlayer: EpisodePlayer = Graph.episodePlayer, - podcastStore: PodcastStore = Graph.podcastStore, - savedStateHandle: SavedStateHandle - ) : this( - episodeStore = episodeStore, - episodePlayer = episodePlayer, - podcastStore = podcastStore, - podcastUri = Uri.decode(savedStateHandle.get(Screen.ARG_PODCAST_URI)!!) - ) + private val podcastUri = Uri.decode(savedStateHandle.get(Screen.ARG_PODCAST_URI)!!) + + init { + Log.d("JetcasterVM", "PodcatURI: $podcastUri") + } val state: StateFlow = combine( From 1f7b898d2ecd1fcd45507fabda2cf2224ecc4deb Mon Sep 17 00:00:00 2001 From: arriolac Date: Tue, 2 Apr 2024 18:55:06 +0000 Subject: [PATCH 082/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 0d239bbc9c..71ed2cfdd9 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -111,12 +111,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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) @Composable From 96d875457353ecf315cc038e513f3b9a51ead08f Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Tue, 26 Mar 2024 20:51:43 +0000 Subject: [PATCH 083/143] add home screen --- .../jetcaster/ui/home/HomeViewModel.kt | 2 +- .../data/repository/PodcastsRepository.kt | 1 + .../com/example/jetcaster/core}/util/Flows.kt | 40 +++- Jetcaster/gradle/libs.versions.toml | 3 +- Jetcaster/wear/build.gradle | 3 + Jetcaster/wear/proguard-rules.pro | 4 + .../com/example/jetcaster/MainActivity.kt | 55 ++++-- .../jetcaster/ui/JetcasterNavController.kt | 50 +++++ .../example/jetcaster/ui/home/HomeScreen.kt | 164 ++++++++++++++++ .../jetcaster/ui/home/HomeViewModel.kt | 180 ++++++++++++++++++ .../ui/home/JetcasterBrowseScreen.kt | 33 ---- .../ui/home/JetcasterBrowseScreenViewModel.kt | 19 -- .../ui/library/LatestEpisodeViewModel.kt | 71 +++++++ .../ui/library/LatestEpisodesScreen.kt | 141 ++++++++++++++ .../src/main/res/drawable/new_releases.xml | 4 + .../wear/src/main/res/drawable/podcast.xml | 3 + .../wear/src/main/res/drawable/refresh.xml | 4 + .../wear/src/main/res/drawable/speed.xml | 4 + .../wear/src/main/res/drawable/up_next.xml | 3 + .../wear/src/main/res/values/strings.xml | 10 +- 20 files changed, 725 insertions(+), 69 deletions(-) rename Jetcaster/{app/src/main/java/com/example/jetcaster => core/src/main/java/com/example/jetcaster/core}/util/Flows.kt (70%) create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt delete mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreen.kt delete mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreenViewModel.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt create mode 100644 Jetcaster/wear/src/main/res/drawable/new_releases.xml create mode 100644 Jetcaster/wear/src/main/res/drawable/podcast.xml create mode 100644 Jetcaster/wear/src/main/res/drawable/refresh.xml create mode 100644 Jetcaster/wear/src/main/res/drawable/speed.xml create mode 100644 Jetcaster/wear/src/main/res/drawable/up_next.xml diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 36a53cdaa0..1a2b16aeff 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -33,7 +33,7 @@ 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.player.EpisodePlayer -import com.example.jetcaster.util.combine +import com.example.jetcaster.core.util.combine import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt index 2458eb5a22..52c71cff78 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt @@ -46,6 +46,7 @@ class PodcastsRepository( if (refreshingJob?.isActive == true) { refreshingJob?.join() } else if (force || podcastStore.isEmpty()) { + refreshingJob = scope.launch { // Now fetch the podcasts, and add each to each store podcastsFetcher(SampleFeeds) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Flows.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/util/Flows.kt similarity index 70% rename from Jetcaster/app/src/main/java/com/example/jetcaster/util/Flows.kt rename to Jetcaster/core/src/main/java/com/example/jetcaster/core/util/Flows.kt index 65276b7378..a9940f315d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Flows.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/util/Flows.kt @@ -14,9 +14,47 @@ * limitations under the License. */ -package com.example.jetcaster.util +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 combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + transform: suspend (T1, T2, T3, T4, T5) -> R +): Flow = + 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 combine( + flow: Flow, + flow2: Flow, + + transform: suspend (T1, T2) -> R +): Flow = + 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. diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index e3ff2372df..d22948965f 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -63,7 +63,7 @@ composeMaterial = "1.2.1" composeFoundation = "1.2.1" coreSplashscreen = "1.0.1" horologistComposeTools = "0.4.8" -horologist = "0.5.25" +horologist = "0.6.6" roborazzi = "1.11.0" androidx-wear-compose = "1.3.0" wear-compose-ui-tooling = "1.3.0" @@ -165,6 +165,7 @@ horologist-compose-material = { module = "com.google.android.horologist:horologi horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" } horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" } horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" } +horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" } horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" } roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index 9e14e9978a..be948257b3 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -87,6 +87,8 @@ dependencies { // https://issuetracker.google.com/issues/new?component=1077552&template=1598429&pli=1 implementation libs.wear.compose.material + implementation(libs.kotlinx.collections.immutable) + // Foundation is additive, so you can use the mobile version in your Wear OS app. implementation libs.wear.compose.foundation implementation(libs.androidx.material.icons.core) @@ -101,6 +103,7 @@ dependencies { implementation libs.horologist.media.ui implementation libs.horologist.audio.ui implementation libs.horologist.media.data + implementation libs.horologist.images.coil // Preview Tooling implementation libs.compose.ui.tooling.preview diff --git a/Jetcaster/wear/proguard-rules.pro b/Jetcaster/wear/proguard-rules.pro index e7f7a95b87..2050fceabe 100644 --- a/Jetcaster/wear/proguard-rules.pro +++ b/Jetcaster/wear/proguard-rules.pro @@ -33,3 +33,7 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +##---------------Begin: proguard configuration for Pusher Java Client ---------- +-dontwarn org.slf4j.impl.StaticLoggerBinder +##---------------End: proguard configuration for Pusher Java Client ---------- \ No newline at end of file diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt index 05a8dfbbbb..a94f2eec7b 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt @@ -25,16 +25,29 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSavedStateRegistryOwner +import androidx.compose.ui.res.stringResource import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.viewmodel.compose.viewModel import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState import com.example.jetcaster.theme.WearAppTheme +import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode +import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext +import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast +import com.example.jetcaster.ui.LatestEpisodes +import com.example.jetcaster.ui.home.HomeScreen +import com.example.jetcaster.ui.home.HomeViewModel +import com.example.jetcaster.ui.library.LatestEpisodeViewModel +import com.example.jetcaster.ui.library.LatestEpisodesScreen import com.example.jetcaster.ui.player.PlayerScreen import com.example.jetcaster.ui.player.PlayerViewModel import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.audio.ui.VolumeViewModel +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberColumnState import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume import com.google.android.horologist.media.ui.navigation.MediaPlayerScaffold import com.google.android.horologist.media.ui.snackbar.SnackbarManager @@ -51,10 +64,11 @@ class MainActivity : ComponentActivity() { } } +@OptIn(ExperimentalHorologistApi::class) @Composable fun WearApp() { - val navController = rememberSwipeDismissableNavController() + val navController = rememberSwipeDismissableNavController() val navHostState = rememberSwipeDismissableNavHostState() val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory) val snackBarManager: SnackbarManager = SnackbarManager() @@ -78,16 +92,13 @@ fun WearApp() { ) }, libraryScreen = { -// val columnState = rememberColumnState() - -// ScreenScaffold(scrollState = columnState) { -// JetcasterBrowseScreen( -// jetcasterBrowseScreenViewModel = JetcasterBrowseScreenViewModel(), -// onPlaylistsClick = { navController.navigateToCollections() }, -// onSettingsClick = { navController.navigateToSettings() }, -// columnState = columnState, -// ) -// } + HomeScreen( + homeViewModel = HomeViewModel(), + onLatestEpisodeClick = { navController.navigateToLatestEpisode() }, + onYourPodcastClick = { navController.navigateToYourPodcast() }, + onUpNextClick = { navController.navigateToUpNext() }, + onErrorDialogCancelClick = { navController.popBackStack() } + ) }, categoryEntityScreen = { _, _ -> }, mediaEntityScreen = {}, @@ -97,10 +108,28 @@ fun WearApp() { navHostState = navHostState, snackbarViewModel = snackbarViewModel, volumeViewModel = volumeViewModel, - timeText = {}, + timeText = { + TimeText() + }, deepLinkPrefix = "", navController = navController, - additionalNavRoutes = {}, + additionalNavRoutes = { + composable( + route = LatestEpisodes.navRoute, + ) { + val columnState = rememberColumnState() + + ScreenScaffold(scrollState = columnState) { + LatestEpisodesScreen( + columnState = columnState, + playlistName = stringResource(id = R.string.latest_episodes), + latestEpisodeViewModel = LatestEpisodeViewModel(), + onShuffleButtonClick = {}, + onPlayButtonClick = {} + ) + } + } + }, ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt new file mode 100644 index 0000000000..c3b9c4d314 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt @@ -0,0 +1,50 @@ +/* + * 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 androidx.navigation.NavController +import com.google.android.horologist.media.ui.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.navigateToUpNext() { + navigate(UpNext.destination()) + } +} + +public object YourPodcasts : NavigationScreens("yourPodcasts") { + public fun destination(): String = navRoute +} + +public object LatestEpisodes : NavigationScreens("latestEpisodes") { + public fun destination(): String = navRoute +} + +public object UpNext : NavigationScreens("upNext") { + public fun destination(): String = navRoute +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt new file mode 100644 index 0000000000..fd0a1808ec --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -0,0 +1,164 @@ +/* + * 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 + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.Text +import com.example.jetcaster.R +import com.example.jetcaster.core.data.model.PodcastInfo +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.images.base.paintable.DrawableResPaintable +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.images.coil.CoilPaintable + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun HomeScreen( + homeViewModel: HomeViewModel, + onLatestEpisodeClick: () -> Unit, + onYourPodcastClick: () -> Unit, + onUpNextClick: () -> Unit, + onErrorDialogCancelClick: () -> Unit, + modifier: Modifier = Modifier +) { + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ), + ) + val viewState by homeViewModel.state.collectAsStateWithLifecycle() + + ScreenScaffold(scrollState = columnState, modifier = modifier) { + ScalingLazyColumn(columnState = columnState) { + if (viewState.featuredPodcasts.isNotEmpty()) { + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.home_library)) + } + } + item { + Chip( + label = stringResource(R.string.latest_episodes), + onClick = onLatestEpisodeClick, + icon = DrawableResPaintable(R.drawable.new_releases), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + Chip( + label = stringResource(R.string.podcasts), + onClick = onYourPodcastClick, + icon = DrawableResPaintable(R.drawable.podcast), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.queue)) + } + } + item { + Chip( + label = stringResource(R.string.up_next), + onClick = onUpNextClick, + icon = DrawableResPaintable(R.drawable.up_next), + colors = ChipDefaults.secondaryChipColors() + ) + } + } else { + item { + AlertDialog( + message = stringResource(R.string.entity_no_featured_podcasts), + showDialog = true, + onDismiss = { onErrorDialogCancelClick }, + content = { + + if (viewState + .podcastCategoryFilterResult + .topPodcasts + .isNotEmpty() + ) { + item { + PodcastContent( + podcast = viewState.podcastCategoryFilterResult + .topPodcasts[0], + downloadItemArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onClick = { + homeViewModel.onTogglePodcastFollowed( + viewState + .podcastCategoryFilterResult + .topPodcasts[0] + .uri + ) + }, + ) + } + } else { + item { + PlaceholderChip( + contentDescription = "", + colors = ChipDefaults.secondaryChipColors() + ) + } + } + } + ) + } + } + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun PodcastContent( + podcast: PodcastInfo, + downloadItemArtworkPlaceholder: Painter?, + onClick: () -> Unit +) { + val mediaTitle = podcast.title + + Chip( + label = mediaTitle, + onClick = onClick, + icon = CoilPaintable(podcast.imageUrl, downloadItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt new file mode 100644 index 0000000000..9aebd9b3e9 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -0,0 +1,180 @@ +/* + * 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 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.di.Graph +import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase +import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase +import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase +import com.example.jetcaster.core.data.model.CategoryInfo +import com.example.jetcaster.core.data.model.FilterableCategoriesModel +import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.data.model.toPlayerEpisode +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.player.EpisodePlayer +import com.example.jetcaster.core.util.combine +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.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModel( + private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, + private val podcastStore: PodcastStore = Graph.podcastStore, + private val episodeStore: EpisodeStore = Graph.episodeStore, + private val getLatestFollowedEpisodesUseCase: GetLatestFollowedEpisodesUseCase = + Graph.getLatestFollowedEpisodesUseCase, + private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase = + Graph.podcastCategoryFilterUseCase, + private val filterableCategoriesUseCase: FilterableCategoriesUseCase = + Graph.filterableCategoriesUseCase, + private val episodePlayer: EpisodePlayer = Graph.episodePlayer +) : ViewModel() { + // Holds our currently selected podcast in the library + private val selectedLibraryPodcast = MutableStateFlow(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(null) + // Holds our view state which the UI collects via [state] + private val _state = MutableStateFlow(HomeViewState()) + // Holds the view state if the UI is refreshing for new data + private val refreshing = MutableStateFlow(false) + + val state: StateFlow + 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. + combine( + homeCategories, + selectedHomeCategory, + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), + refreshing, + _selectedCategory.flatMapLatest { selectedCategory -> + filterableCategoriesUseCase(selectedCategory) + }, + _selectedCategory.flatMapLatest { + podcastCategoryFilterUseCase(it) + }, + selectedLibraryPodcast.flatMapLatest { + episodeStore.episodesInPodcast( + podcastUri = it?.uri ?: "", + limit = 20 + ) + } + ) { homeCategories, + homeCategory, + podcasts, + refreshing, + filterableCategories, + podcastCategoryFilterResult, + libraryEpisodes -> + + _selectedCategory.value = filterableCategories.selectedCategory + + selectedHomeCategory.value = homeCategory + + HomeViewState( + homeCategories = homeCategories, + selectedHomeCategory = homeCategory, + featuredPodcasts = podcasts.toPersistentList(), + refreshing = refreshing, + filterableCategoriesModel = filterableCategories, + podcastCategoryFilterResult = podcastCategoryFilterResult, + libraryEpisodes = libraryEpisodes, + errorMessage = null, /* TODO */ + ) + }.catch { throwable -> + // TODO: emit a UI error here. For now we'll just rethrow + throw throwable + }.collect { + _state.value = it + } + } + + refresh(force = false) + } + + private fun refresh(force: Boolean) { + viewModelScope.launch { + runCatching { + refreshing.value = true + podcastsRepository.updatePodcasts(force) + } + // TODO: look at result of runCatching and show any errors + + refreshing.value = false + } + } + + fun onHomeCategorySelected(category: HomeCategory) { + selectedHomeCategory.value = category + } + + fun onPodcastUnfollowed(podcastUri: String) { + viewModelScope.launch { + podcastStore.unfollowPodcast(podcastUri) + } + } + + fun onTogglePodcastFollowed(podcastUri: String) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcastUri) + } + } + + fun onLibraryPodcastSelected(podcast: Podcast?) { + selectedLibraryPodcast.value = podcast + } + + fun onQueuePodcast(episodeToPodcast: EpisodeToPodcast) { + episodePlayer.addToQueue(episodeToPodcast.toPlayerEpisode()) + } +} + +enum class HomeCategory { + Library, Discover +} + +data class HomeViewState( + val featuredPodcasts: PersistentList = persistentListOf(), + val refreshing: Boolean = false, + val selectedHomeCategory: HomeCategory = HomeCategory.Discover, + val homeCategories: List = emptyList(), + val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), + val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), + val libraryEpisodes: List = emptyList(), + val errorMessage: String? = null +) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreen.kt deleted file mode 100644 index 03f1f551d5..0000000000 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreen.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.google.android.horologist.annotations.ExperimentalHorologistApi - -@OptIn(ExperimentalHorologistApi::class) -@Composable -fun JetcasterBrowseScreen( - jetcasterBrowseScreenViewModel: JetcasterBrowseScreenViewModel, - onLatestEpisodeClick: () -> Unit, - onPodcastClick: () -> Unit, - modifier: Modifier = Modifier, -) { -} - -data class BrowseScreenState(val name: String) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreenViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreenViewModel.kt deleted file mode 100644 index 8cc19d84dc..0000000000 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/JetcasterBrowseScreenViewModel.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class JetcasterBrowseScreenViewModel diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt new file mode 100644 index 0000000000..8d193ae673 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase +import com.example.jetcaster.core.util.combine +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch + +class LatestEpisodeViewModel( + private val episodesFromFavouritePodcasts: GetLatestFollowedEpisodesUseCase = + Graph.getLatestFollowedEpisodesUseCase, +) : ViewModel() { + // Holds our view state which the UI collects via [state] + private val _state = MutableStateFlow(LatestEpisodeViewState()) + // Holds the view state if the UI is refreshing for new data + private val refreshing = MutableStateFlow(false) + val state: StateFlow + 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. + combine( + episodesFromFavouritePodcasts.invoke(), + refreshing + ) { + libraryEpisodes, + refreshing + -> + + LatestEpisodeViewState( + refreshing = refreshing, + libraryEpisodes = libraryEpisodes, + errorMessage = null, /* TODO */ + ) + }.catch { throwable -> + // TODO: emit a UI error here. For now we'll just rethrow + throw throwable + }.collect { + _state.value = it + } + } + } +} +data class LatestEpisodeViewState( + val refreshing: Boolean = false, + val libraryEpisodes: List = emptyList(), + val errorMessage: String? = null +) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt new file mode 100644 index 0000000000..956268d291 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material.ChipDefaults +import com.example.jetcaster.R +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.compose.layout.ScalingLazyColumnState +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.images.coil.CoilPaintable +import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@OptIn(ExperimentalHorologistApi::class) +@Composable +public fun LatestEpisodesScreen( + columnState: ScalingLazyColumnState, + playlistName: String, + latestEpisodeViewModel: LatestEpisodeViewModel, + onShuffleButtonClick: (EpisodeToPodcast) -> Unit, + onPlayButtonClick: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, +) { + val viewState by latestEpisodeViewModel.state.collectAsStateWithLifecycle() + + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + EntityScreen( + columnState = columnState, + headerContent = { DefaultEntityScreenHeader(title = playlistName) }, + content = { + items(count = viewState.libraryEpisodes.size) { index -> + MediaContent( + episode = viewState.libraryEpisodes[index], + downloadItemArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ) + ) + } + }, + modifier = modifier, + buttonsContent = { + ButtonsContent( + onShuffleButtonClick = onShuffleButtonClick, + onPlayButtonClick = onPlayButtonClick, + ) + }, + ) + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun MediaContent( + episode: EpisodeToPodcast, + downloadItemArtworkPlaceholder: Painter? +) { + val mediaTitle = episode.episode.title + + val secondaryLabel = episode.episode.author + + Chip( + label = mediaTitle, + onClick = { /*play*/ }, + secondaryLabel = secondaryLabel, + icon = CoilPaintable(episode.podcast.imageUrl, downloadItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun ButtonsContent( + onShuffleButtonClick: (EpisodeToPodcast) -> Unit, + onPlayButtonClick: (EpisodeToPodcast) -> Unit, +) { + + Row( + modifier = Modifier + .padding(bottom = 16.dp) + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Button( + imageVector = ImageVector.vectorResource(R.drawable.speed), + contentDescription = stringResource(id = R.string.speed_button_content_description), + onClick = { /*onShuffleButtonClick(state.collectionModel)*/ }, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + + Button( + imageVector = Icons.Filled.PlayArrow, + contentDescription = stringResource(id = R.string.button_play_content_description), + onClick = { /*onPlayButtonClick(state.)*/ }, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + } +} diff --git a/Jetcaster/wear/src/main/res/drawable/new_releases.xml b/Jetcaster/wear/src/main/res/drawable/new_releases.xml new file mode 100644 index 0000000000..12cfa723c3 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/new_releases.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/podcast.xml b/Jetcaster/wear/src/main/res/drawable/podcast.xml new file mode 100644 index 0000000000..93d4a641a0 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/podcast.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/refresh.xml b/Jetcaster/wear/src/main/res/drawable/refresh.xml new file mode 100644 index 0000000000..f1c3a12a44 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/refresh.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/speed.xml b/Jetcaster/wear/src/main/res/drawable/speed.xml new file mode 100644 index 0000000000..be0c51bd94 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/speed.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/up_next.xml b/Jetcaster/wear/src/main/res/drawable/up_next.xml new file mode 100644 index 0000000000..19c71208db --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/up_next.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index 1af68dc831..d72386237d 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -18,12 +18,20 @@ Unable to fetch podcasts feeds.\nCheck your internet connection and try again. Retry - Your podcasts + Podcasts Latest episodes Your library + Queue + Up Next Discover + Settings + Your library is empty. Checkout the latest podcasts. + Cancel + Refresh + Change Speed + Play Updated a while ago Updated %d week ago From 82ce7ca92dfcf3addad8048fe197634d003dd96f Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 2 Apr 2024 14:03:57 -0700 Subject: [PATCH 084/143] Remove log. --- .../example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 36de9083d2..d654d5ee51 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -18,7 +18,6 @@ package com.example.jetcaster.ui.podcast import android.net.Uri import android.os.Bundle -import android.util.Log import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -59,10 +58,6 @@ class PodcastDetailsViewModel( private val podcastUri = Uri.decode(savedStateHandle.get(Screen.ARG_PODCAST_URI)!!) - init { - Log.d("JetcasterVM", "PodcatURI: $podcastUri") - } - val state: StateFlow = combine( podcastStore.podcastWithExtraInfo(podcastUri), From 4b90571b5a4c3ec5ffcf70a999d721302c9791f6 Mon Sep 17 00:00:00 2001 From: Jonathan Koren Date: Wed, 3 Apr 2024 10:08:28 -0700 Subject: [PATCH 085/143] Hide back arrow in supporting pane when 2 panes shown --- .../com/example/jetcaster/ui/JetcasterApp.kt | 3 ++- .../java/com/example/jetcaster/ui/home/Home.kt | 10 +++++++++- .../jetcaster/ui/podcast/PodcastDetailsScreen.kt | 16 +++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index d028717c35..ece26ca3e3 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -113,7 +113,8 @@ fun JetcasterApp( navigateToPlayer = { episodePlayer -> appState.navigateToPlayer(episodePlayer.uri, backStackEntry) }, - navigateBack = appState::navigateBack + navigateBack = appState::navigateBack, + showBackButton = true, ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 71ed2cfdd9..3d404acedc 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -66,8 +66,10 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue 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.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass @@ -118,6 +120,11 @@ import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun ThreePaneScaffoldNavigator.isMainPaneHidden(): Boolean { + return scaffoldValue[SupportingPaneScaffoldRole.Main] == PaneAdaptedValue.Hidden +} + @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun MainScreen( @@ -154,7 +161,8 @@ fun MainScreen( if (navigator.canNavigateBack()) { navigator.navigateBack() } - } + }, + showBackButton = navigator.isMainPaneHidden(), ) } }, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index 066bf44e72..b24e9d87de 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -80,6 +80,7 @@ fun PodcastDetailsScreen( viewModel: PodcastDetailsViewModel, navigateToPlayer: (EpisodeInfo) -> Unit, navigateBack: () -> Unit, + showBackButton: Boolean, modifier: Modifier = Modifier ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -97,6 +98,7 @@ fun PodcastDetailsScreen( onQueueEpisode = viewModel::onQueueEpisode, navigateToPlayer = navigateToPlayer, navigateBack = navigateBack, + showBackButton = showBackButton, modifier = modifier, ) } @@ -118,6 +120,7 @@ fun PodcastDetailsScreen( onQueueEpisode: (PlayerEpisode) -> Unit, navigateToPlayer: (EpisodeInfo) -> Unit, navigateBack: () -> Unit, + showBackButton: Boolean, modifier: Modifier = Modifier ) { val coroutineScope = rememberCoroutineScope() @@ -126,10 +129,12 @@ fun PodcastDetailsScreen( Scaffold( modifier = modifier.fillMaxSize(), topBar = { - PodcastDetailsTopAppBar( - navigateBack = navigateBack, - modifier = Modifier.fillMaxWidth() - ) + if (showBackButton) { + PodcastDetailsTopAppBar( + navigateBack = navigateBack, + modifier = Modifier.fillMaxWidth() + ) + } }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) @@ -352,6 +357,7 @@ fun PodcastDetailsScreenPreview() { toggleSubscribe = { }, onQueueEpisode = { }, navigateToPlayer = { }, - navigateBack = { } + navigateBack = { }, + showBackButton = true, ) } From c323b6c79ce80cc98753f318bc775bcc3619fec9 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 2 Apr 2024 13:48:49 +0100 Subject: [PATCH 086/143] [Jetcaster] Handling no podcast selected and extracting state for home --- .../com/example/jetcaster/ui/home/Home.kt | 170 ++++++++++-------- 1 file changed, 94 insertions(+), 76 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 71ed2cfdd9..6920af8e39 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -118,6 +118,26 @@ import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch +data class HomeState( + val windowSizeClass: WindowSizeClass, + val featuredPodcasts: PersistentList, + val isRefreshing: Boolean, + val selectedHomeCategory: HomeCategory, + val homeCategories: List, + val filterableCategoriesModel: FilterableCategoriesModel, + val podcastCategoryFilterResult: PodcastCategoryFilterResult, + val library: LibraryInfo, + val modifier: Modifier = Modifier, + val onPodcastUnfollowed: (PodcastInfo) -> Unit, + val onHomeCategorySelected: (HomeCategory) -> Unit, + val onCategorySelected: (CategoryInfo) -> Unit, + val navigateToPodcastDetails: (PodcastInfo) -> Unit, + val navigateToPlayer: (EpisodeInfo) -> Unit, + val onTogglePodcastFollowed: (PodcastInfo) -> Unit, + val onLibraryPodcastSelected: (PodcastInfo?) -> Unit, + val onQueueEpisode: (PlayerEpisode) -> Unit, +) + @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun MainScreen( @@ -130,14 +150,42 @@ fun MainScreen( BackHandler(enabled = navigator.canNavigateBack()) { navigator.navigateBack() } + + val homeState = HomeState( + windowSizeClass = windowSizeClass, + featuredPodcasts = viewState.featuredPodcasts, + isRefreshing = viewState.refreshing, + homeCategories = viewState.homeCategories, + selectedHomeCategory = viewState.selectedHomeCategory, + filterableCategoriesModel = viewState.filterableCategoriesModel, + podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, + library = viewState.library, + onHomeCategorySelected = viewModel::onHomeCategorySelected, + onCategorySelected = viewModel::onCategorySelected, + onPodcastUnfollowed = viewModel::onPodcastUnfollowed, + navigateToPodcastDetails = { + navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri) + }, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, + onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, + onQueueEpisode = viewModel::onQueueEpisode + ) + Surface { - SupportingPaneScaffold( - value = navigator.scaffoldValue, - directive = navigator.scaffoldDirective, - supportingPane = { - val podcastUri = navigator.currentDestination?.content - ?: viewState.featuredPodcasts.firstOrNull()?.uri - if (!podcastUri.isNullOrEmpty()) { + val podcastUri = navigator.currentDestination?.content + ?: viewState.featuredPodcasts.firstOrNull()?.uri + + if (podcastUri.isNullOrEmpty()) { + HomeScreen( + homeState = homeState, + modifier = Modifier.fillMaxSize() + ) + } else { + SupportingPaneScaffold( + value = navigator.scaffoldValue, + directive = navigator.scaffoldDirective, + supportingPane = { val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( key = podcastUri, factory = PodcastDetailsViewModel.provideFactory( @@ -156,33 +204,16 @@ fun MainScreen( } } ) - } - }, - mainPane = { - HomeScreen( - windowSizeClass = windowSizeClass, - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - filterableCategoriesModel = viewState.filterableCategoriesModel, - podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, - library = viewState.library, - onHomeCategorySelected = viewModel::onHomeCategorySelected, - onCategorySelected = viewModel::onCategorySelected, - onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - navigateToPodcastDetails = { - navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri) - }, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, - onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected, - onQueueEpisode = viewModel::onQueueEpisode, - modifier = Modifier.fillMaxSize() - ) - }, - modifier = Modifier.fillMaxSize() - ) + }, + mainPane = { + HomeScreen( + homeState = homeState, + modifier = Modifier.fillMaxSize() + ) + }, + modifier = Modifier.fillMaxSize() + ) + } } } @@ -242,28 +273,13 @@ private fun HomeAppBar( @Composable private fun HomeScreen( - windowSizeClass: WindowSizeClass, - featuredPodcasts: PersistentList, - isRefreshing: Boolean, - selectedHomeCategory: HomeCategory, - homeCategories: List, - filterableCategoriesModel: FilterableCategoriesModel, - podcastCategoryFilterResult: PodcastCategoryFilterResult, - library: LibraryInfo, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (PodcastInfo) -> Unit, - onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (CategoryInfo) -> Unit, - navigateToPodcastDetails: (PodcastInfo) -> Unit, - navigateToPlayer: (EpisodeInfo) -> Unit, - onTogglePodcastFollowed: (PodcastInfo) -> Unit, - onLibraryPodcastSelected: (PodcastInfo?) -> Unit, - onQueueEpisode: (PlayerEpisode) -> Unit, + homeState: HomeState, + modifier: Modifier = Modifier ) { // Effect that changes the home category selection when there are no subscribed podcasts - LaunchedEffect(key1 = featuredPodcasts) { - if (featuredPodcasts.isEmpty()) { - onHomeCategorySelected(HomeCategory.Discover) + LaunchedEffect(key1 = homeState.featuredPodcasts) { + if (homeState.featuredPodcasts.isEmpty()) { + homeState.onHomeCategorySelected(HomeCategory.Discover) } } @@ -275,10 +291,10 @@ private fun HomeScreen( ), topBar = { HomeAppBar( - selectedHomeCategory = selectedHomeCategory, - homeCategories = homeCategories, - onHomeCategorySelected = onHomeCategorySelected, - showHomeCategoryToggle = !windowSizeClass.isCompact, + selectedHomeCategory = homeState.selectedHomeCategory, + homeCategories = homeState.homeCategories, + onHomeCategorySelected = homeState.onHomeCategorySelected, + showHomeCategoryToggle = !homeState.windowSizeClass.isCompact, modifier = Modifier.fillMaxWidth(), ) }, @@ -289,27 +305,27 @@ private fun HomeScreen( // Main Content val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) HomeContent( - showGrid = !windowSizeClass.isCompact, - featuredPodcasts = featuredPodcasts, - isRefreshing = isRefreshing, - selectedHomeCategory = selectedHomeCategory, - homeCategories = homeCategories, - filterableCategoriesModel = filterableCategoriesModel, - podcastCategoryFilterResult = podcastCategoryFilterResult, - library = library, + showGrid = !homeState.windowSizeClass.isCompact, + featuredPodcasts = homeState.featuredPodcasts, + isRefreshing = homeState.isRefreshing, + selectedHomeCategory = homeState.selectedHomeCategory, + homeCategories = homeState.homeCategories, + filterableCategoriesModel = homeState.filterableCategoriesModel, + podcastCategoryFilterResult = homeState.podcastCategoryFilterResult, + library = homeState.library, modifier = Modifier.padding(contentPadding), - onPodcastUnfollowed = onPodcastUnfollowed, - onHomeCategorySelected = onHomeCategorySelected, - onCategorySelected = onCategorySelected, - navigateToPodcastDetails = navigateToPodcastDetails, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed, - onLibraryPodcastSelected = onLibraryPodcastSelected, + onPodcastUnfollowed = homeState.onPodcastUnfollowed, + onHomeCategorySelected = homeState.onHomeCategorySelected, + onCategorySelected = homeState.onCategorySelected, + navigateToPodcastDetails = homeState.navigateToPodcastDetails, + navigateToPlayer = homeState.navigateToPlayer, + onTogglePodcastFollowed = homeState.onTogglePodcastFollowed, + onLibraryPodcastSelected = homeState.onLibraryPodcastSelected, onQueueEpisode = { coroutineScope.launch { snackbarHostState.showSnackbar(snackBarText) } - onQueueEpisode(it) + homeState.onQueueEpisode(it) } ) } @@ -730,7 +746,7 @@ private val CompactWindowSizeClass = WindowSizeClass.calculateFromSize( @Composable private fun PreviewHomeContent() { JetcasterTheme { - HomeScreen( + val homeState = HomeState( windowSizeClass = CompactWindowSizeClass, featuredPodcasts = PreviewPodcasts.toPersistentList(), isRefreshing = false, @@ -754,6 +770,7 @@ private fun PreviewHomeContent() { onLibraryPodcastSelected = {}, onQueueEpisode = {} ) + HomeScreen(homeState = homeState) } } @@ -763,7 +780,7 @@ private fun PreviewHomeContent() { @Composable private fun PreviewHomeContentExpanded() { JetcasterTheme { - HomeScreen( + val homeState = HomeState( windowSizeClass = CompactWindowSizeClass, featuredPodcasts = PreviewPodcasts.toPersistentList(), isRefreshing = false, @@ -787,6 +804,7 @@ private fun PreviewHomeContentExpanded() { onLibraryPodcastSelected = {}, onQueueEpisode = {} ) + HomeScreen(homeState = homeState) } } From a106ddc11b77a75062da4c3d7ab253ade3c125c8 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 2 Apr 2024 11:13:35 -0700 Subject: [PATCH 087/143] Add hilt. --- Jetcaster/app/build.gradle.kts | 14 +- .../example/jetcaster/JetcasterApplication.kt | 16 +- .../com/example/jetcaster/ui/JetcasterApp.kt | 64 +------ .../com/example/jetcaster/ui/MainActivity.kt | 2 + .../com/example/jetcaster/ui/home/Home.kt | 26 +-- .../jetcaster/ui/home/HomeViewModel.kt | 20 +-- .../jetcaster/ui/player/PlayerScreen.kt | 5 +- .../jetcaster/ui/player/PlayerViewModel.kt | 38 +---- .../ui/podcast/PodcastDetailsViewModel.kt | 56 ++---- Jetcaster/build.gradle.kts | 2 + Jetcaster/core/build.gradle.kts | 10 ++ .../example/jetcaster/core/data/Dispatcher.kt | 12 ++ .../jetcaster/core/data/di/CoreDiModule.kt | 160 ++++++++++++++++++ .../example/jetcaster/core/data/di/Graph.kt | 27 +-- .../domain/FilterableCategoriesUseCase.kt | 3 +- .../GetLatestFollowedEpisodesUseCase.kt | 8 +- .../domain/PodcastCategoryFilterUseCase.kt | 3 +- .../core/data/network/PodcastFetcher.kt | 15 +- .../core/data/repository/CategoryStore.kt | 3 +- .../core/data/repository/PodcastStore.kt | 2 +- .../data/repository/PodcastsRepository.kt | 7 +- .../core/player/MockEpisodePlayer.kt | 4 +- 22 files changed, 276 insertions(+), 221 deletions(-) create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt create mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index 713bfc50f5..80b1348d07 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) + alias(libs.plugins.hilt) } android { @@ -95,18 +96,21 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.palette) - implementation(libs.androidx.activity.compose) - - implementation(libs.androidx.constraintlayout.compose) + // 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.ui) + 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.material3.window) - implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt index 1493a62e3f..45cf1e705b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt @@ -20,20 +20,16 @@ import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory import com.example.jetcaster.core.data.di.Graph +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject /** * Application which sets up our dependency [Graph] with a context. */ +@HiltAndroidApp class JetcasterApplication : Application(), ImageLoaderFactory { - override fun onCreate() { - super.onCreate() - Graph.provide(this) - } - override fun newImageLoader(): ImageLoader { - return ImageLoader.Builder(this) - // Disable `Cache-Control` header support as some podcast images disable disk caching. - .respectCacheHeaders(false) - .build() - } + @Inject lateinit var imageLoader: ImageLoader + + override fun newImageLoader(): ImageLoader = imageLoader } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index ece26ca3e3..93f63a9fa6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -16,32 +16,18 @@ package com.example.jetcaster.ui -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.EaseIn -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.window.layout.DisplayFeature import com.example.jetcaster.R -import com.example.jetcaster.core.data.di.Graph.episodePlayer -import com.example.jetcaster.core.data.di.Graph.episodeStore -import com.example.jetcaster.core.data.di.Graph.podcastStore import com.example.jetcaster.ui.home.MainScreen import com.example.jetcaster.ui.player.PlayerScreen -import com.example.jetcaster.ui.player.PlayerViewModel -import com.example.jetcaster.ui.podcast.PodcastDetailsScreen -import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel @Composable fun JetcasterApp( @@ -62,61 +48,13 @@ fun JetcasterApp( } ) } - composable(Screen.Player.route) { backStackEntry -> - val playerViewModel: PlayerViewModel = viewModel( - factory = PlayerViewModel.provideFactory( - owner = backStackEntry, - defaultArgs = backStackEntry.arguments - ) - ) + composable(Screen.Player.route) { PlayerScreen( windowSizeClass, displayFeatures, - playerViewModel, onBackPress = appState::navigateBack ) } - composable( - route = Screen.PodcastDetails.route, - enterTransition = { - fadeIn( - animationSpec = tween( - 300, easing = LinearEasing - ) - ) + slideIntoContainer( - animationSpec = tween(300, easing = EaseIn), - towards = AnimatedContentTransitionScope.SlideDirection.Start - ) - }, - exitTransition = { - fadeOut( - animationSpec = tween( - 300, easing = LinearEasing - ) - ) + slideOutOfContainer( - animationSpec = tween(300, easing = EaseOut), - towards = AnimatedContentTransitionScope.SlideDirection.End - ) - } - ) { backStackEntry -> - val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( - factory = PodcastDetailsViewModel.provideFactory( - episodeStore = episodeStore, - podcastStore = podcastStore, - episodePlayer = episodePlayer, - owner = backStackEntry, - defaultArgs = backStackEntry.arguments - ) - ) - PodcastDetailsScreen( - viewModel = podcastDetailsViewModel, - navigateToPlayer = { episodePlayer -> - appState.navigateToPlayer(episodePlayer.uri, backStackEntry) - }, - navigateBack = appState::navigateBack, - showBackButton = true, - ) - } } } else { OfflineDialog { appState.refreshOnline() } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt index 8218625f3b..5e41c34b1b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt @@ -24,7 +24,9 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import com.example.jetcaster.ui.theme.JetcasterTheme import com.google.accompanist.adaptive.calculateDisplayFeatures +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 24463855bd..cd524bbeda 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -84,16 +84,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalSavedStateRegistryOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.core.os.bundleOf +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.data.model.CategoryInfo @@ -103,7 +101,6 @@ import com.example.jetcaster.core.data.model.LibraryInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.ui.Screen import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.home.library.libraryItems import com.example.jetcaster.ui.podcast.PodcastDetailsScreen @@ -113,12 +110,12 @@ 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 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime data class HomeState( val windowSizeClass: WindowSizeClass, @@ -150,7 +147,7 @@ private fun ThreePaneScaffoldNavigator.isMainPaneHidden(): Boolean { fun MainScreen( windowSizeClass: WindowSizeClass, navigateToPlayer: (EpisodeInfo) -> Unit, - viewModel: HomeViewModel = viewModel() + viewModel: HomeViewModel = hiltViewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() val navigator = rememberSupportingPaneScaffoldNavigator() @@ -193,15 +190,10 @@ fun MainScreen( value = navigator.scaffoldValue, directive = navigator.scaffoldDirective, supportingPane = { - val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel( - key = podcastUri, - factory = PodcastDetailsViewModel.provideFactory( - owner = LocalSavedStateRegistryOwner.current, - defaultArgs = bundleOf( - Screen.ARG_PODCAST_URI to podcastUri - ) - ) - ) + val podcastDetailsViewModel = + hiltViewModel { + it.create(podcastUri) + } PodcastDetailsScreen( viewModel = podcastDetailsViewModel, navigateToPlayer = navigateToPlayer, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 1a2b16aeff..cca4ffe8f6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -19,7 +19,6 @@ package com.example.jetcaster.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase import com.example.jetcaster.core.data.model.CategoryInfo @@ -34,6 +33,7 @@ import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -43,17 +43,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch +import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) -class HomeViewModel( - private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, - private val podcastStore: PodcastStore = Graph.podcastStore, - private val episodeStore: EpisodeStore = Graph.episodeStore, - private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase = - Graph.podcastCategoryFilterUseCase, - private val filterableCategoriesUseCase: FilterableCategoriesUseCase = - Graph.filterableCategoriesUseCase, - private val episodePlayer: EpisodePlayer = Graph.episodePlayer +@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(null) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 6da07c69ac..93c7b8e0c7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -81,6 +81,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import coil.compose.AsyncImage @@ -105,8 +106,8 @@ import java.time.Duration fun PlayerScreen( windowSizeClass: WindowSizeClass, displayFeatures: List, - viewModel: PlayerViewModel, - onBackPress: () -> Unit + onBackPress: () -> Unit, + viewModel: PlayerViewModel = hiltViewModel(), ) { val uiState = viewModel.uiState PlayerScreen( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 64ec980f68..b1e15992bc 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -17,26 +17,24 @@ package com.example.jetcaster.ui.player import android.net.Uri -import android.os.Bundle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.savedstate.SavedStateRegistryOwner -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.model.toPlayerEpisode 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.ui.Screen -import java.time.Duration +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.time.Duration +import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() @@ -46,9 +44,10 @@ data class PlayerUiState( * ViewModel that handles the business logic and screen state of the Player screen */ @OptIn(ExperimentalCoroutinesApi::class) -class PlayerViewModel( - episodeStore: EpisodeStore = Graph.episodeStore, - private val episodePlayer: EpisodePlayer = Graph.episodePlayer, +@HiltViewModel +class PlayerViewModel @Inject constructor( + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -100,27 +99,4 @@ class PlayerViewModel( fun onRewindBy(duration: Duration) { episodePlayer.rewindBy(duration) } - - /** - * Factory for PlayerViewModel that takes EpisodeStore, PodcastStore and EpisodePlayer as a - * dependency - */ - companion object { - fun provideFactory( - episodeStore: EpisodeStore = Graph.episodeStore, - episodePlayer: EpisodePlayer = Graph.episodePlayer, - owner: SavedStateRegistryOwner, - defaultArgs: Bundle? = null, - ): AbstractSavedStateViewModelFactory = - object : AbstractSavedStateViewModelFactory(owner, defaultArgs) { - @Suppress("UNCHECKED_CAST") - override fun create( - key: String, - modelClass: Class, - handle: SavedStateHandle - ): T { - return PlayerViewModel(episodeStore, episodePlayer, handle) as T - } - } - } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index d654d5ee51..22c78fa9c9 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -17,13 +17,8 @@ package com.example.jetcaster.ui.podcast import android.net.Uri -import android.os.Bundle -import androidx.lifecycle.AbstractSavedStateViewModelFactory -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.savedstate.SavedStateRegistryOwner -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.model.EpisodeInfo import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.data.model.PodcastInfo @@ -31,7 +26,10 @@ import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.player.EpisodePlayer -import com.example.jetcaster.ui.Screen +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 @@ -49,19 +47,20 @@ sealed interface PodcastUiState { /** * ViewModel that handles the business logic and screen state of the Podcast details screen. */ -class PodcastDetailsViewModel( - private val episodeStore: EpisodeStore = Graph.episodeStore, - private val episodePlayer: EpisodePlayer = Graph.episodePlayer, - private val podcastStore: PodcastStore = Graph.podcastStore, - savedStateHandle: SavedStateHandle +@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 podcastUri = Uri.decode(savedStateHandle.get(Screen.ARG_PODCAST_URI)!!) + private val decodedPodcastUri = Uri.decode(podcastUri) val state: StateFlow = combine( - podcastStore.podcastWithExtraInfo(podcastUri), - episodeStore.episodesInPodcast(podcastUri) + podcastStore.podcastWithExtraInfo(decodedPodcastUri), + episodeStore.episodesInPodcast(decodedPodcastUri) ) { podcast, episodeToPodcasts -> val episodes = episodeToPodcasts.map { it.episode.asExternalModel() } PodcastUiState.Ready( @@ -84,31 +83,8 @@ class PodcastDetailsViewModel( episodePlayer.addToQueue(playerEpisode) } - /** - * Factory for [PodcastDetailsViewModel]. - */ - companion object { - fun provideFactory( - episodeStore: EpisodeStore = Graph.episodeStore, - podcastStore: PodcastStore = Graph.podcastStore, - episodePlayer: EpisodePlayer = Graph.episodePlayer, - owner: SavedStateRegistryOwner, - defaultArgs: Bundle? = null, - ): AbstractSavedStateViewModelFactory = - object : AbstractSavedStateViewModelFactory(owner, defaultArgs) { - @Suppress("UNCHECKED_CAST") - override fun create( - key: String, - modelClass: Class, - handle: SavedStateHandle - ): T { - return PodcastDetailsViewModel( - episodeStore = episodeStore, - episodePlayer = episodePlayer, - podcastStore = podcastStore, - savedStateHandle = handle - ) as T - } - } + @AssistedFactory + interface Factory { + fun create(podcastUri: String): PodcastDetailsViewModel } } diff --git a/Jetcaster/build.gradle.kts b/Jetcaster/build.gradle.kts index c9c401efd5..d3fc5aca1b 100644 --- a/Jetcaster/build.gradle.kts +++ b/Jetcaster/build.gradle.kts @@ -20,6 +20,8 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.gradle.versions) alias(libs.plugins.version.catalog.update) + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false } apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") diff --git a/Jetcaster/core/build.gradle.kts b/Jetcaster/core/build.gradle.kts index b82ab25e3a..2457fad326 100644 --- a/Jetcaster/core/build.gradle.kts +++ b/Jetcaster/core/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) + alias(libs.plugins.hilt) } // TODO(chris): Set up convention plugin @@ -38,14 +39,23 @@ dependencies { 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) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt new file mode 100644 index 0000000000..c5f5f348ae --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt @@ -0,0 +1,12 @@ +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, +} diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt new file mode 100644 index 0000000000..93c3d1cd43 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt @@ -0,0 +1,160 @@ +package com.example.jetcaster.core.data.di + +import android.content.Context +import androidx.room.Room +import coil.ImageLoader +import com.example.jetcaster.core.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.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.MockEpisodePlayer +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 kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.logging.LoggingEventListener +import java.io.File +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CoreDiModule { + + @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, + ) + + @Provides + @Singleton + fun provideEpisodePlayer( + @Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher + ): EpisodePlayer = MockEpisodePlayer(mainDispatcher) +} diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt index c53f2f024d..039512676e 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt @@ -17,12 +17,9 @@ package com.example.jetcaster.core.data.di import android.content.Context -import androidx.room.Room -import com.example.jetcaster.core.BuildConfig import com.example.jetcaster.core.data.database.JetcasterDatabase import com.example.jetcaster.core.data.database.dao.TransactionRunner import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase -import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase import com.example.jetcaster.core.data.network.PodcastsFetcher import com.example.jetcaster.core.data.repository.CategoryStore @@ -33,12 +30,9 @@ import com.example.jetcaster.core.data.repository.LocalPodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.core.player.MockEpisodePlayer import com.rometools.rome.io.SyndFeedInput -import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import okhttp3.Cache import okhttp3.OkHttpClient -import okhttp3.logging.LoggingEventListener /** * A very simple global singleton dependency graph. @@ -93,13 +87,6 @@ object Graph { ) } - val getLatestFollowedEpisodesUseCase by lazy { - GetLatestFollowedEpisodesUseCase( - episodeStore = episodeStore, - podcastStore = podcastStore - ) - } - val podcastCategoryFilterUseCase by lazy { PodcastCategoryFilterUseCase( categoryStore = categoryStore @@ -128,17 +115,7 @@ object Graph { get() = Dispatchers.IO fun provide(context: Context) { - okHttpClient = OkHttpClient.Builder() - .cache(Cache(File(context.cacheDir, "http_cache"), (20 * 1024 * 1024).toLong())) - .apply { - if (BuildConfig.DEBUG) eventListenerFactory(LoggingEventListener.Factory()) - } - .build() - - database = 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() + okHttpClient = CoreDiModule.provideOkHttpClient(context) + database = CoreDiModule.provideDatabase(context) } } diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt index eedf1f708b..97c0738dc4 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt @@ -22,11 +22,12 @@ import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.repository.CategoryStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import javax.inject.Inject /** * Use case for categories that can be used to filter podcasts. */ -class FilterableCategoriesUseCase( +class FilterableCategoriesUseCase @Inject constructor( private val categoryStore: CategoryStore ) { /** diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt index 98f273b4fa..08d007c052 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt @@ -17,19 +17,19 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest +import javax.inject.Inject /** * A use case which returns all the latest episodes from all the podcasts the user follows. */ -class GetLatestFollowedEpisodesUseCase( - private val episodeStore: EpisodeStore = Graph.episodeStore, - private val podcastStore: PodcastStore = Graph.podcastStore, +class GetLatestFollowedEpisodesUseCase @Inject constructor( + private val episodeStore: EpisodeStore, + private val podcastStore: PodcastStore, ) { @OptIn(ExperimentalCoroutinesApi::class) operator fun invoke(): Flow> = diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt index de978cc18d..2bec115659 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt @@ -25,11 +25,12 @@ import com.example.jetcaster.core.data.repository.CategoryStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject /** * A use case which returns top podcasts and matching episodes in a given [Category]. */ -class PodcastCategoryFilterUseCase( +class PodcastCategoryFilterUseCase @Inject constructor( private val categoryStore: CategoryStore ) { operator fun invoke(category: CategoryInfo?): Flow { diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt index eaf619487d..7cb5045f58 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt @@ -17,6 +17,8 @@ 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 @@ -25,10 +27,6 @@ import com.rometools.modules.itunes.FeedInformation 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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow @@ -39,6 +37,11 @@ import kotlinx.coroutines.withContext import okhttp3.CacheControl import okhttp3.OkHttpClient import okhttp3.Request +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit +import javax.inject.Inject /** * A class which fetches some selected podcast RSS feeds. @@ -47,10 +50,10 @@ import okhttp3.Request * @param syndFeedInput [SyndFeedInput] to use for parsing RSS feeds. * @param ioDispatcher [CoroutineDispatcher] to use for running fetch requests. */ -class PodcastsFetcher( +class PodcastsFetcher @Inject constructor( private val okHttpClient: OkHttpClient, private val syndFeedInput: SyndFeedInput, - private val ioDispatcher: CoroutineDispatcher + @Dispatcher(JetcasterDispatchers.IO) private val ioDispatcher: CoroutineDispatcher ) { /** diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt index 8d067157d0..9e926520d5 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt @@ -25,6 +25,7 @@ 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 +import javax.inject.Inject interface CategoryStore { /** @@ -66,7 +67,7 @@ interface CategoryStore { /** * A data repository for [Category] instances. */ -class LocalCategoryStore( +class LocalCategoryStore constructor( private val categoriesDao: CategoriesDao, private val categoryEntryDao: PodcastCategoryEntryDao, private val episodesDao: EpisodesDao, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt index 1241082aa7..ee809c9e30 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -91,7 +91,7 @@ interface PodcastStore { /** * A data repository for [Podcast] instances. */ -class LocalPodcastStore( +class LocalPodcastStore constructor( private val podcastDao: PodcastsDao, private val podcastFollowedEntryDao: PodcastFollowedEntryDao, private val transactionRunner: TransactionRunner diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt index 52c71cff78..1198650e60 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt @@ -16,6 +16,8 @@ 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 @@ -26,17 +28,18 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import javax.inject.Inject /** * Data repository for Podcasts. */ -class PodcastsRepository( +class PodcastsRepository @Inject constructor( private val podcastsFetcher: PodcastsFetcher, private val podcastStore: PodcastStore, private val episodeStore: EpisodeStore, private val categoryStore: CategoryStore, private val transactionRunner: TransactionRunner, - mainDispatcher: CoroutineDispatcher + @Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher ) { private var refreshingJob: Job? = null diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 34662b24da..14dfcbd1bd 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -17,8 +17,6 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.data.model.PlayerEpisode -import java.time.Duration -import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -31,6 +29,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.time.Duration +import kotlin.reflect.KProperty class MockEpisodePlayer( private val mainDispatcher: CoroutineDispatcher From 7634e9ddb638931dab2bd90bac15c760123ee877 Mon Sep 17 00:00:00 2001 From: arriolac Date: Tue, 2 Apr 2024 22:19:34 +0000 Subject: [PATCH 088/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/jetcaster/ui/home/Home.kt | 6 +++--- .../jetcaster/ui/home/HomeViewModel.kt | 2 +- .../jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../example/jetcaster/core/data/Dispatcher.kt | 16 +++++++++++++++ .../jetcaster/core/data/di/CoreDiModule.kt | 20 +++++++++++++++++-- .../domain/FilterableCategoriesUseCase.kt | 2 +- .../GetLatestFollowedEpisodesUseCase.kt | 2 +- .../domain/PodcastCategoryFilterUseCase.kt | 2 +- .../core/data/network/PodcastFetcher.kt | 10 +++++----- .../core/data/repository/CategoryStore.kt | 1 - .../data/repository/PodcastsRepository.kt | 2 +- .../core/player/MockEpisodePlayer.kt | 4 ++-- 12 files changed, 51 insertions(+), 20 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index cd524bbeda..376e255c91 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -110,12 +110,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 data class HomeState( val windowSizeClass: WindowSizeClass, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index cca4ffe8f6..bfeddd3e28 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -34,6 +34,7 @@ import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine 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 @@ -43,7 +44,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch -import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index b1e15992bc..7748a10c20 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -29,12 +29,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState 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 -import java.time.Duration -import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt index c5f5f348ae..a57199979c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt index 93c3d1cd43..7878b9be97 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt @@ -1,3 +1,19 @@ +/* + * 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 @@ -27,13 +43,13 @@ 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 -import java.io.File -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt index 97c0738dc4..c7255b9466 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt @@ -20,9 +20,9 @@ import com.example.jetcaster.core.data.model.CategoryInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.repository.CategoryStore +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import javax.inject.Inject /** * Use case for categories that can be used to filter podcasts. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt index 08d007c052..8d87799302 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt @@ -19,10 +19,10 @@ package com.example.jetcaster.core.data.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 -import javax.inject.Inject /** * A use case which returns all the latest episodes from all the podcasts the user follows. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt index 2bec115659..68aa0d22a6 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt @@ -22,10 +22,10 @@ import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult import com.example.jetcaster.core.data.model.asExternalModel import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.CategoryStore +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf -import javax.inject.Inject /** * A use case which returns top podcasts and matching episodes in a given [Category]. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt index 7cb5045f58..34e7030c93 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt @@ -27,6 +27,11 @@ import com.rometools.modules.itunes.FeedInformation 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 @@ -37,11 +42,6 @@ import kotlinx.coroutines.withContext import okhttp3.CacheControl import okhttp3.OkHttpClient import okhttp3.Request -import java.time.Duration -import java.time.Instant -import java.time.ZoneOffset -import java.util.concurrent.TimeUnit -import javax.inject.Inject /** * A class which fetches some selected podcast RSS feeds. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt index 9e926520d5..a69d082652 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt @@ -25,7 +25,6 @@ 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 -import javax.inject.Inject interface CategoryStore { /** diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt index 1198650e60..cb9b308405 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt @@ -22,13 +22,13 @@ 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 -import javax.inject.Inject /** * Data repository for Podcasts. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 14dfcbd1bd..34662b24da 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -17,6 +17,8 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.data.model.PlayerEpisode +import java.time.Duration +import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -29,8 +31,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.time.Duration -import kotlin.reflect.KProperty class MockEpisodePlayer( private val mainDispatcher: CoroutineDispatcher From b136c750e55b50773f82050af9e86b13d3f28e7d Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 3 Apr 2024 10:37:59 -0700 Subject: [PATCH 089/143] Add Hilt to TV and Wear --- .../example/jetcaster/JetcasterApplication.kt | 1 - .../com/example/jetcaster/ui/home/Home.kt | 10 +- .../example/jetcaster/core/data/di/Graph.kt | 121 ------------------ Jetcaster/tv-app/build.gradle.kts | 8 +- .../example/jetcaster/tv/JetCasterTvApp.kt | 11 +- .../com/example/jetcaster/tv/MainActivity.kt | 2 + .../example/jetcaster/tv/ui/JetcasterApp.kt | 11 -- .../tv/ui/discover/DiscoverScreen.kt | 4 +- .../tv/ui/discover/DiscoverScreenViewModel.kt | 10 +- .../jetcaster/tv/ui/episode/EpisodeScreen.kt | 4 +- .../tv/ui/episode/EpisodeScreenViewModel.kt | 26 +--- .../jetcaster/tv/ui/library/LibraryScreen.kt | 4 +- .../tv/ui/library/LibraryScreenViewModel.kt | 12 +- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 3 +- .../tv/ui/podcast/PodcastScreenViewModel.kt | 26 +--- .../jetcaster/tv/ui/search/SearchScreen.kt | 4 +- .../tv/ui/search/SearchScreenViewModel.kt | 12 +- Jetcaster/wear/build.gradle | 10 +- .../jetcaster/JetcasterWearApplication.kt | 15 +-- .../com/example/jetcaster/MainActivity.kt | 14 +- .../example/jetcaster/ui/home/HomeScreen.kt | 5 +- .../jetcaster/ui/home/HomeViewModel.kt | 23 ++-- .../ui/library/LatestEpisodeViewModel.kt | 9 +- .../ui/library/LatestEpisodesScreen.kt | 3 +- .../jetcaster/ui/player/PlayerScreen.kt | 3 +- .../jetcaster/ui/player/PlayerViewModel.kt | 33 +---- 26 files changed, 104 insertions(+), 280 deletions(-) delete mode 100644 Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt index 45cf1e705b..120187d2d5 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt @@ -19,7 +19,6 @@ package com.example.jetcaster import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory -import com.example.jetcaster.core.data.di.Graph import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 376e255c91..f8cf9c3e24 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -110,12 +110,12 @@ 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 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime data class HomeState( val windowSizeClass: WindowSizeClass, @@ -191,7 +191,9 @@ fun MainScreen( directive = navigator.scaffoldDirective, supportingPane = { val podcastDetailsViewModel = - hiltViewModel { + hiltViewModel( + key = podcastUri + ) { it.create(podcastUri) } PodcastDetailsScreen( diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt deleted file mode 100644 index 039512676e..0000000000 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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.di - -import android.content.Context -import com.example.jetcaster.core.data.database.JetcasterDatabase -import com.example.jetcaster.core.data.database.dao.TransactionRunner -import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase -import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase -import com.example.jetcaster.core.data.network.PodcastsFetcher -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.PodcastsRepository -import com.example.jetcaster.core.player.MockEpisodePlayer -import com.rometools.rome.io.SyndFeedInput -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import okhttp3.OkHttpClient - -/** - * A very simple global singleton dependency graph. - * - * For a real app, you would use something like Hilt/Dagger instead. - */ -object Graph { - lateinit var okHttpClient: OkHttpClient - - lateinit var database: JetcasterDatabase - private set - - private val transactionRunner: TransactionRunner - get() = database.transactionRunnerDao() - - private val syndFeedInput by lazy { SyndFeedInput() } - - val episodePlayer by lazy { - MockEpisodePlayer(mainDispatcher) - } - - val podcastRepository by lazy { - PodcastsRepository( - podcastsFetcher = podcastFetcher, - podcastStore = podcastStore, - episodeStore = episodeStore, - categoryStore = categoryStore, - transactionRunner = transactionRunner, - mainDispatcher = mainDispatcher - ) - } - - private val podcastFetcher by lazy { - PodcastsFetcher( - okHttpClient = okHttpClient, - syndFeedInput = syndFeedInput, - ioDispatcher = ioDispatcher - ) - } - - val podcastStore by lazy { - LocalPodcastStore( - podcastDao = database.podcastsDao(), - podcastFollowedEntryDao = database.podcastFollowedEntryDao(), - transactionRunner = transactionRunner - ) - } - - val episodeStore: EpisodeStore by lazy { - LocalEpisodeStore( - episodesDao = database.episodesDao() - ) - } - - val podcastCategoryFilterUseCase by lazy { - PodcastCategoryFilterUseCase( - categoryStore = categoryStore - ) - } - - val filterableCategoriesUseCase by lazy { - FilterableCategoriesUseCase( - categoryStore = categoryStore - ) - } - - val categoryStore: CategoryStore by lazy { - LocalCategoryStore( - categoriesDao = database.categoriesDao(), - categoryEntryDao = database.podcastCategoryEntryDao(), - episodesDao = database.episodesDao(), - podcastsDao = database.podcastsDao() - ) - } - - private val mainDispatcher: CoroutineDispatcher - get() = Dispatchers.Main - - private val ioDispatcher: CoroutineDispatcher - get() = Dispatchers.IO - - fun provide(context: Context) { - okHttpClient = CoreDiModule.provideOkHttpClient(context) - database = CoreDiModule.provideDatabase(context) - } -} diff --git a/Jetcaster/tv-app/build.gradle.kts b/Jetcaster/tv-app/build.gradle.kts index d748aaa616..36045066e3 100644 --- a/Jetcaster/tv-app/build.gradle.kts +++ b/Jetcaster/tv-app/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) + alias(libs.plugins.hilt) } android { @@ -79,6 +80,11 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.coil.kt.compose) + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(project(":core")) implementation(project(":designsystem")) @@ -89,4 +95,4 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) coreLibraryDesugaring(libs.core.jdk.desugaring) -} \ No newline at end of file +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt index 413098fc13..0d85c0b841 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt @@ -17,12 +17,7 @@ package com.example.jetcaster.tv import android.app.Application -import com.example.jetcaster.core.data.di.Graph +import dagger.hilt.android.HiltAndroidApp -class JetCasterTvApp : Application() { - - override fun onCreate() { - super.onCreate() - Graph.provide(this) - } -} +@HiltAndroidApp +class JetCasterTvApp : Application() diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt index ec52101124..6052428d09 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt @@ -29,7 +29,9 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.example.jetcaster.tv.ui.JetcasterApp import com.example.jetcaster.tv.ui.theme.JetcasterTheme +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index 19025262e0..c9da96f1e8 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -29,7 +29,6 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -40,10 +39,8 @@ 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.episode.EpisodeScreenViewModel import com.example.jetcaster.tv.ui.library.LibraryScreen import com.example.jetcaster.tv.ui.podcast.PodcastScreen -import com.example.jetcaster.tv.ui.podcast.PodcastScreenViewModel import com.example.jetcaster.tv.ui.profile.ProfileScreen import com.example.jetcaster.tv.ui.search.SearchScreen import com.example.jetcaster.tv.ui.settings.SettingsScreen @@ -165,11 +162,7 @@ private fun Route(jetcasterAppState: JetcasterAppState) { } composable(Screen.Podcast.route) { - val podcastScreenViewModel: PodcastScreenViewModel = viewModel( - factory = PodcastScreenViewModel.factory - ) PodcastScreen( - podcastScreenViewModel = podcastScreenViewModel, backToHomeScreen = jetcasterAppState::navigateToDiscover, playEpisode = {}, showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.episode.uri) }, @@ -180,15 +173,11 @@ private fun Route(jetcasterAppState: JetcasterAppState) { } composable(Screen.Episode.route) { - val episodeScreenViewModel: EpisodeScreenViewModel = viewModel( - factory = EpisodeScreenViewModel.factory - ) EpisodeScreen( playEpisode = { jetcasterAppState.playEpisode(it.uri) }, backToHome = jetcasterAppState::navigateToDiscover, - episodeScreenViewModel = episodeScreenViewModel, ) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 2f5548b2e7..efcadd57b0 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.foundation.lazy.list.TvLazyListState import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -52,7 +52,7 @@ fun DiscoverScreen( showPodcastDetails: (Podcast) -> Unit, showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, - discoverScreenViewModel: DiscoverScreenViewModel = viewModel() + discoverScreenViewModel: DiscoverScreenViewModel = hiltViewModel() ) { val uiState by discoverScreenViewModel.uiState.collectAsState() diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 3947036353..6448755a69 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -19,12 +19,12 @@ package com.example.jetcaster.tv.ui.discover import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.CategoryStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -34,10 +34,12 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject -class DiscoverScreenViewModel( - private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, - private val categoryStore: CategoryStore = Graph.categoryStore, +@HiltViewModel +class DiscoverScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val categoryStore: CategoryStore, ) : ViewModel() { private val _selectedCategory = MutableStateFlow(null) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt index 76bdba6d50..a24780dda7 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -50,7 +50,7 @@ fun EpisodeScreen( playEpisode: (Episode) -> Unit, backToHome: () -> Unit, modifier: Modifier = Modifier, - episodeScreenViewModel: EpisodeScreenViewModel = viewModel() + episodeScreenViewModel: EpisodeScreenViewModel = hiltViewModel() ) { val uiState by episodeScreenViewModel.uiStateFlow.collectAsState() diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index e3bacc7480..b3d879b652 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -16,26 +16,27 @@ package com.example.jetcaster.tv.ui.episode -import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Episode import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.tv.ui.Screen +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject -class EpisodeScreenViewModel( +@HiltViewModel +class EpisodeScreenViewModel @Inject constructor( handle: SavedStateHandle, - podcastsRepository: PodcastsRepository = Graph.podcastRepository, - episodeStore: EpisodeStore = Graph.episodeStore, + podcastsRepository: PodcastsRepository, + episodeStore: EpisodeStore, ) : ViewModel() { private val episodeUri = handle.get(Screen.Episode.PARAMETER_NAME) @@ -70,21 +71,6 @@ class EpisodeScreenViewModel( podcastsRepository.updatePodcasts(false) } } - - companion object { - @Suppress("UNCHECKED_CAST") - val factory = object : AbstractSavedStateViewModelFactory() { - override fun create( - key: String, - modelClass: Class, - handle: SavedStateHandle - ): T { - return EpisodeScreenViewModel( - handle - ) as T - } - } - } } sealed interface EpisodeScreenUiState { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index bdbf1b5625..0b52136073 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -31,7 +31,7 @@ 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.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme @@ -51,7 +51,7 @@ fun LibraryScreen( navigateToDiscover: () -> Unit, showPodcastDetails: (PodcastWithExtraInfo) -> Unit, showEpisodeDetails: (EpisodeToPodcast) -> Unit, - libraryScreenViewModel: LibraryScreenViewModel = viewModel() + libraryScreenViewModel: LibraryScreenViewModel = hiltViewModel() ) { val uiState by libraryScreenViewModel.uiState.collectAsState() when (val s = uiState) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index 1855cbb192..79cbabef4b 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -18,12 +18,12 @@ package com.example.jetcaster.tv.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.di.Graph 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.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -32,11 +32,13 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject -class LibraryScreenViewModel( - private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, - private val episodeStore: EpisodeStore = Graph.episodeStore, - podcastStore: PodcastStore = Graph.podcastStore, +@HiltViewModel +class LibraryScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val episodeStore: EpisodeStore, + podcastStore: PodcastStore, ) : ViewModel() { private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode().map { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index 9c33abfb4e..c704d8f591 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.items import androidx.tv.material3.ButtonDefaults @@ -62,11 +63,11 @@ import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable fun PodcastScreen( - podcastScreenViewModel: PodcastScreenViewModel, backToHomeScreen: () -> Unit, playEpisode: (Episode) -> Unit, showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, + podcastScreenViewModel: PodcastScreenViewModel = hiltViewModel(), ) { val uiState by podcastScreenViewModel.uiStateFlow.collectAsState() when (val s = uiState) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt index 8b39200abd..76f4a1ba19 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -16,16 +16,15 @@ package com.example.jetcaster.tv.ui.podcast -import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Podcast -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.ui.Screen +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -34,11 +33,13 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject -class PodcastScreenViewModel( +@HiltViewModel +class PodcastScreenViewModel @Inject constructor( handle: SavedStateHandle, - private val podcastStore: PodcastStore = Graph.podcastStore, - episodeStore: EpisodeStore = Graph.episodeStore, + private val podcastStore: PodcastStore, + episodeStore: EpisodeStore, ) : ViewModel() { private val podcastUri = handle.get(Screen.Podcast.PARAMETER_NAME) @@ -98,21 +99,6 @@ class PodcastScreenViewModel( } } } - - companion object { - @Suppress("UNCHECKED_CAST") - val factory = object : AbstractSavedStateViewModelFactory() { - override fun create( - key: String, - modelClass: Class, - handle: SavedStateHandle - ): T { - return PodcastScreenViewModel( - handle - ) as T - } - } - } } sealed interface PodcastScreenUiState { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt index dd05155161..813cd19597 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -45,7 +45,7 @@ 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.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvGridItemSpan import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid @@ -68,7 +68,7 @@ import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults fun SearchScreen( onPodcastSelected: (PodcastWithExtraInfo) -> Unit, modifier: Modifier = Modifier, - searchScreenViewModel: SearchScreenViewModel = viewModel() + searchScreenViewModel: SearchScreenViewModel = hiltViewModel() ) { val uiState by searchScreenViewModel.uiStateFlow.collectAsState() diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt index 1f222d5b0f..254d3f1f04 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt @@ -19,7 +19,6 @@ package com.example.jetcaster.tv.ui.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.CategoryStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository @@ -27,6 +26,7 @@ import com.example.jetcaster.tv.model.CategoryList 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -35,11 +35,13 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject -class SearchScreenViewModel( - private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, - private val podcastStore: PodcastStore = Graph.podcastStore, - categoryStore: CategoryStore = Graph.categoryStore, +@HiltViewModel +class SearchScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val podcastStore: PodcastStore, + categoryStore: CategoryStore, ) : ViewModel() { private val keywordFlow = MutableStateFlow("") diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index be948257b3..1cb72263a6 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -14,10 +14,11 @@ * limitations under the License. */ plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) alias libs.plugins.roborazzi alias(libs.plugins.ksp) + alias(libs.plugins.hilt) } android { @@ -105,6 +106,11 @@ dependencies { implementation libs.horologist.media.data implementation libs.horologist.images.coil + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + // Preview Tooling implementation libs.compose.ui.tooling.preview implementation(libs.androidx.compose.ui.tooling) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt index 6c6f8fddc9..a633b967a8 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt @@ -20,13 +20,16 @@ import android.app.Application import android.os.StrictMode import coil.ImageLoader import coil.ImageLoaderFactory -import com.example.jetcaster.core.data.di.Graph +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +@HiltAndroidApp class JetcasterWearApplication : Application(), ImageLoaderFactory { + @Inject lateinit var imageLoader: ImageLoader + override fun onCreate() { super.onCreate() - Graph.provide(this) setStrictMode() } @@ -41,10 +44,6 @@ class JetcasterWearApplication : Application(), ImageLoaderFactory { ) } - override fun newImageLoader(): ImageLoader { - return ImageLoader.Builder(this) - // Disable `Cache-Control` header support as some podcast images disable disk caching. - .respectCacheHeaders(false) - .build() - } + override fun newImageLoader(): ImageLoader = + imageLoader } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt index a94f2eec7b..33045b249d 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt @@ -24,7 +24,6 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSavedStateRegistryOwner import androidx.compose.ui.res.stringResource import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.viewmodel.compose.viewModel @@ -39,11 +38,8 @@ 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.home.HomeScreen -import com.example.jetcaster.ui.home.HomeViewModel -import com.example.jetcaster.ui.library.LatestEpisodeViewModel import com.example.jetcaster.ui.library.LatestEpisodesScreen import com.example.jetcaster.ui.player.PlayerScreen -import com.example.jetcaster.ui.player.PlayerViewModel import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.compose.layout.ScreenScaffold @@ -52,7 +48,9 @@ import com.google.android.horologist.media.ui.navigation.MediaNavController.navi import com.google.android.horologist.media.ui.navigation.MediaPlayerScaffold import com.google.android.horologist.media.ui.snackbar.SnackbarManager import com.google.android.horologist.media.ui.snackbar.SnackbarViewModel +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -79,12 +77,6 @@ fun WearApp() { playerScreen = { PlayerScreen( modifier = Modifier.fillMaxSize(), - - playerScreenViewModel = viewModel( - factory = PlayerViewModel.provideFactory( - owner = LocalSavedStateRegistryOwner.current - ) - ), volumeViewModel = volumeViewModel, onVolumeClick = { navController.navigateToVolume() @@ -93,7 +85,6 @@ fun WearApp() { }, libraryScreen = { HomeScreen( - homeViewModel = HomeViewModel(), onLatestEpisodeClick = { navController.navigateToLatestEpisode() }, onYourPodcastClick = { navController.navigateToYourPodcast() }, onUpNextClick = { navController.navigateToUpNext() }, @@ -123,7 +114,6 @@ fun WearApp() { LatestEpisodesScreen( columnState = columnState, playlistName = stringResource(id = R.string.latest_episodes), - latestEpisodeViewModel = LatestEpisodeViewModel(), onShuffleButtonClick = {}, onPlayButtonClick = {} ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt index fd0a1808ec..041c3355d7 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Text @@ -46,12 +47,12 @@ import com.google.android.horologist.images.coil.CoilPaintable @OptIn(ExperimentalHorologistApi::class) @Composable fun HomeScreen( - homeViewModel: HomeViewModel, onLatestEpisodeClick: () -> Unit, onYourPodcastClick: () -> Unit, onUpNextClick: () -> Unit, onErrorDialogCancelClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + homeViewModel: HomeViewModel = hiltViewModel(), ) { val columnState = rememberResponsiveColumnState( contentPadding = ScalingLazyColumnDefaults.padding( diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 9aebd9b3e9..ddc4b63c25 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -21,9 +21,7 @@ import androidx.lifecycle.viewModelScope 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.di.Graph import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase -import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase import com.example.jetcaster.core.data.model.CategoryInfo import com.example.jetcaster.core.data.model.FilterableCategoriesModel @@ -34,6 +32,7 @@ import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -43,19 +42,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch +import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) -class HomeViewModel( - private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, - private val podcastStore: PodcastStore = Graph.podcastStore, - private val episodeStore: EpisodeStore = Graph.episodeStore, - private val getLatestFollowedEpisodesUseCase: GetLatestFollowedEpisodesUseCase = - Graph.getLatestFollowedEpisodesUseCase, - private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase = - Graph.podcastCategoryFilterUseCase, - private val filterableCategoriesUseCase: FilterableCategoriesUseCase = - Graph.filterableCategoriesUseCase, - private val episodePlayer: EpisodePlayer = Graph.episodePlayer +@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(null) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index 8d193ae673..5837e848ed 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -19,17 +19,18 @@ package com.example.jetcaster.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.util.combine +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch +import javax.inject.Inject -class LatestEpisodeViewModel( - private val episodesFromFavouritePodcasts: GetLatestFollowedEpisodesUseCase = - Graph.getLatestFollowedEpisodesUseCase, +@HiltViewModel +class LatestEpisodeViewModel @Inject constructor( + private val episodesFromFavouritePodcasts: GetLatestFollowedEpisodesUseCase, ) : ViewModel() { // Holds our view state which the UI collects via [state] private val _state = MutableStateFlow(LatestEpisodeViewState()) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt index 956268d291..ba4879b2e7 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import com.example.jetcaster.R @@ -52,10 +53,10 @@ import com.google.android.horologist.media.ui.screens.entity.EntityScreen public fun LatestEpisodesScreen( columnState: ScalingLazyColumnState, playlistName: String, - latestEpisodeViewModel: LatestEpisodeViewModel, onShuffleButtonClick: (EpisodeToPodcast) -> Unit, onPlayButtonClick: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, + latestEpisodeViewModel: LatestEpisodeViewModel = hiltViewModel() ) { val viewState by latestEpisodeViewModel.state.collectAsStateWithLifecycle() diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 9a99a1c919..92263c45bb 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalView +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.rememberActiveFocusRequester @@ -56,10 +57,10 @@ import com.google.android.horologist.media.ui.state.PlayerUiState @OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class) @Composable fun PlayerScreen( - playerScreenViewModel: PlayerViewModel, volumeViewModel: VolumeViewModel, onVolumeClick: () -> Unit, modifier: Modifier = Modifier, + playerScreenViewModel: PlayerViewModel = hiltViewModel(), ) { val volumeUiState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle() // val settingsState by playerScreenViewModel.settingsState.collectAsStateWithLifecycle() diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 667d938e58..27b0d5e1ee 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -17,21 +17,19 @@ package com.example.jetcaster.ui.player import android.net.Uri -import android.os.Bundle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.savedstate.SavedStateRegistryOwner -import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore -import java.time.Duration +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.time.Duration +import javax.inject.Inject data class PlayerUiState( val title: String = "", @@ -46,7 +44,8 @@ data class PlayerUiState( /** * ViewModel that handles the business logic and screen state of the Player screen */ -class PlayerViewModel( +@HiltViewModel +class PlayerViewModel @Inject constructor( episodeStore: EpisodeStore, podcastStore: PodcastStore, savedStateHandle: SavedStateHandle @@ -79,26 +78,4 @@ class PlayerViewModel( } } } - - /** - * Factory for PlayerViewModel that takes EpisodeStore and PodcastStore as a dependency - */ - companion object { - fun provideFactory( - episodeStore: EpisodeStore = Graph.episodeStore, - podcastStore: PodcastStore = Graph.podcastStore, - owner: SavedStateRegistryOwner, - defaultArgs: Bundle? = null, - ): AbstractSavedStateViewModelFactory = - object : AbstractSavedStateViewModelFactory(owner, defaultArgs) { - @Suppress("UNCHECKED_CAST") - override fun create( - key: String, - modelClass: Class, - handle: SavedStateHandle - ): T { - return PlayerViewModel(episodeStore, podcastStore, handle) as T - } - } - } } From f6c1e6ba89a40aeb282b2f9c6698e0a25c5e2e24 Mon Sep 17 00:00:00 2001 From: arriolac Date: Wed, 3 Apr 2024 18:34:27 +0000 Subject: [PATCH 090/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- .../jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/library/LibraryScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt | 2 +- .../example/jetcaster/tv/ui/search/SearchScreenViewModel.kt | 2 +- .../java/com/example/jetcaster/ui/home/HomeViewModel.kt | 2 +- .../example/jetcaster/ui/library/LatestEpisodeViewModel.kt | 2 +- .../java/com/example/jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index f8cf9c3e24..df4f32ce67 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -110,12 +110,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 data class HomeState( val windowSizeClass: WindowSizeClass, diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 6448755a69..19fca0bb9a 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -25,6 +25,7 @@ import com.example.jetcaster.tv.model.CategoryList 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 @@ -34,7 +35,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class DiscoverScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index b3d879b652..8518074e29 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -25,12 +25,12 @@ import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.tv.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index 79cbabef4b..92a968a201 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -24,6 +24,7 @@ import com.example.jetcaster.core.data.repository.PodcastsRepository 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 @@ -32,7 +33,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class LibraryScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt index 76f4a1ba19..c791355d9a 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -25,6 +25,7 @@ import com.example.jetcaster.core.data.repository.PodcastStore 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 @@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class PodcastScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt index 254d3f1f04..24863951ae 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt @@ -27,6 +27,7 @@ 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 @@ -35,7 +36,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class SearchScreenViewModel @Inject constructor( diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index ddc4b63c25..5bed543e20 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -33,6 +33,7 @@ import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine 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 @@ -42,7 +43,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch -import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index 5837e848ed..9a13538d06 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -22,11 +22,11 @@ import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class LatestEpisodeViewModel @Inject constructor( diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 27b0d5e1ee..723b4a105e 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -26,10 +26,10 @@ import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import java.time.Duration import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch data class PlayerUiState( val title: String = "", From f6403a3330025b0a21afcf1e95c5da1c411717cc Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 3 Apr 2024 16:23:37 -0700 Subject: [PATCH 091/143] Update button text in podcast details. --- .../example/jetcaster/ui/podcast/PodcastDetailsScreen.kt | 9 ++++++++- Jetcaster/app/src/main/res/values/strings.xml | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index b24e9d87de..0237a4eb1d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -287,6 +288,12 @@ fun PodcastDetailsHeaderItemButtons( Row(modifier.padding(top = 16.dp)) { Button( onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = if (isSubscribed) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.secondary + ), modifier = Modifier.semantics(mergeDescendants = true) { } ) { Icon( @@ -298,7 +305,7 @@ fun PodcastDetailsHeaderItemButtons( ) Text( text = if (isSubscribed) - stringResource(id = R.string.unsubscribe) + stringResource(id = R.string.subscribed) else stringResource(id = R.string.subscribe), modifier = Modifier.padding(start = 8.dp) diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index 793d511189..d21cc705a0 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -59,7 +59,7 @@ Episode added to your queue Podcast image Subscribe - Unsubscribe + Subscribed see more Search for a podcast From 2c7e599224a577b3582451264cbedfcce1e02425 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Thu, 4 Apr 2024 11:15:15 -0700 Subject: [PATCH 092/143] Hide home category toggle when there are no subscribed podcasts. --- .../com/example/jetcaster/ui/home/Home.kt | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index df4f32ce67..5a2c07c69f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -69,10 +69,12 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -108,14 +110,13 @@ import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel import com.example.jetcaster.ui.theme.JetcasterTheme 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 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime data class HomeState( val windowSizeClass: WindowSizeClass, @@ -137,6 +138,16 @@ data class HomeState( val onQueueEpisode: (PlayerEpisode) -> Unit, ) +private val HomeState.showHowCategoryTabs: Boolean + get() = featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty() + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun HomeState.showGrid( + scaffoldValue: ThreePaneScaffoldValue +) : Boolean = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || + (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Medium && + scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden) + @OptIn(ExperimentalMaterial3AdaptiveApi::class) private fun ThreePaneScaffoldNavigator.isMainPaneHidden(): Boolean { return scaffoldValue[SupportingPaneScaffoldRole.Main] == PaneAdaptedValue.Hidden @@ -179,10 +190,11 @@ fun MainScreen( Surface { val podcastUri = navigator.currentDestination?.content ?: viewState.featuredPodcasts.firstOrNull()?.uri - + val showGrid = homeState.showGrid(navigator.scaffoldValue) if (podcastUri.isNullOrEmpty()) { HomeScreen( homeState = homeState, + showGrid = showGrid, modifier = Modifier.fillMaxSize() ) } else { @@ -210,6 +222,7 @@ fun MainScreen( mainPane = { HomeScreen( homeState = homeState, + showGrid = showGrid, modifier = Modifier.fillMaxSize() ) }, @@ -266,6 +279,7 @@ private fun HomeAppBar( contentDescription = stringResource(R.string.cd_account) ) }, + modifier = if (showHomeCategoryToggle) Modifier else Modifier.fillMaxWidth() ) { } } }, @@ -276,6 +290,7 @@ private fun HomeAppBar( @Composable private fun HomeScreen( homeState: HomeState, + showGrid: Boolean, modifier: Modifier = Modifier ) { // Effect that changes the home category selection when there are no subscribed podcasts @@ -296,7 +311,7 @@ private fun HomeScreen( selectedHomeCategory = homeState.selectedHomeCategory, homeCategories = homeState.homeCategories, onHomeCategorySelected = homeState.onHomeCategorySelected, - showHomeCategoryToggle = !homeState.windowSizeClass.isCompact, + showHomeCategoryToggle = showGrid && homeState.showHowCategoryTabs, modifier = Modifier.fillMaxWidth(), ) }, @@ -307,7 +322,8 @@ private fun HomeScreen( // Main Content val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) HomeContent( - showGrid = !homeState.windowSizeClass.isCompact, + showGrid = showGrid, + showHomeCategoryTabs = homeState.showHowCategoryTabs, featuredPodcasts = homeState.featuredPodcasts, isRefreshing = homeState.isRefreshing, selectedHomeCategory = homeState.selectedHomeCategory, @@ -336,6 +352,7 @@ private fun HomeScreen( @Composable private fun HomeContent( showGrid: Boolean, + showHomeCategoryTabs: Boolean, featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, @@ -386,6 +403,7 @@ private fun HomeContent( } else { HomeContentColumn( pagerState = pagerState, + showHomeCategoryTabs = showHomeCategoryTabs, featuredPodcasts = featuredPodcasts, isRefreshing = isRefreshing, selectedHomeCategory = selectedHomeCategory, @@ -408,6 +426,7 @@ private fun HomeContent( @OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeContentColumn( + showHomeCategoryTabs: Boolean, pagerState: PagerState, featuredPodcasts: PersistentList, isRefreshing: Boolean, @@ -443,7 +462,7 @@ private fun HomeContentColumn( // TODO show a progress indicator or similar } - if (featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty()) { + if (showHomeCategoryTabs) { stickyHeader { HomeCategoryTabs( categories = homeCategories, @@ -772,7 +791,10 @@ private fun PreviewHomeContent() { onLibraryPodcastSelected = {}, onQueueEpisode = {} ) - HomeScreen(homeState = homeState) + HomeScreen( + homeState = homeState, + showGrid = false + ) } } @@ -806,7 +828,10 @@ private fun PreviewHomeContentExpanded() { onLibraryPodcastSelected = {}, onQueueEpisode = {} ) - HomeScreen(homeState = homeState) + HomeScreen( + homeState = homeState, + showGrid = true + ) } } From 189b951d20cf11aaee655423c57a66fd3085686a Mon Sep 17 00:00:00 2001 From: arriolac Date: Thu, 4 Apr 2024 18:18:29 +0000 Subject: [PATCH 093/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/jetcaster/ui/home/Home.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 5a2c07c69f..a0b16590af 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -111,12 +111,12 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.fullWidthItem import com.example.jetcaster.util.quantityStringResource -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 data class HomeState( val windowSizeClass: WindowSizeClass, @@ -144,9 +144,11 @@ private val HomeState.showHowCategoryTabs: Boolean @OptIn(ExperimentalMaterial3AdaptiveApi::class) private fun HomeState.showGrid( scaffoldValue: ThreePaneScaffoldValue -) : Boolean = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || - (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Medium && - scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden) +): Boolean = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || + ( + windowSizeClass.widthSizeClass == WindowWidthSizeClass.Medium && + scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden + ) @OptIn(ExperimentalMaterial3AdaptiveApi::class) private fun ThreePaneScaffoldNavigator.isMainPaneHidden(): Boolean { From c59e2e400399c3f4f12500256f9b63b0f66619b8 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Thu, 4 Apr 2024 13:51:47 -0700 Subject: [PATCH 094/143] Hide supporting pane on initial load --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index a0b16590af..ce3c8434b0 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -191,7 +191,6 @@ fun MainScreen( Surface { val podcastUri = navigator.currentDestination?.content - ?: viewState.featuredPodcasts.firstOrNull()?.uri val showGrid = homeState.showGrid(navigator.scaffoldValue) if (podcastUri.isNullOrEmpty()) { HomeScreen( From 198dc15a00c2246279cee61ed6a50139f8c3026c Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Thu, 4 Apr 2024 15:15:40 -0700 Subject: [PATCH 095/143] Remove tabs from top app bar. --- .../com/example/jetcaster/ui/home/Home.kt | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index ce3c8434b0..bde8d3d361 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -22,6 +22,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi 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.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -110,13 +111,14 @@ import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel import com.example.jetcaster.ui.theme.JetcasterTheme 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 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime data class HomeState( val windowSizeClass: WindowSizeClass, @@ -138,7 +140,7 @@ data class HomeState( val onQueueEpisode: (PlayerEpisode) -> Unit, ) -private val HomeState.showHowCategoryTabs: Boolean +private val HomeState.showHomeCategoryTabs: Boolean get() = featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty() @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -236,29 +238,17 @@ fun MainScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun HomeAppBar( - selectedHomeCategory: HomeCategory, - homeCategories: List, - onHomeCategorySelected: (HomeCategory) -> Unit, + isExpanded: Boolean, modifier: Modifier = Modifier, - showHomeCategoryToggle: Boolean = false, ) { TopAppBar( title = { Row( + horizontalArrangement = Arrangement.End, modifier = Modifier .fillMaxWidth() .padding(end = 16.dp) ) { - if (showHomeCategoryToggle) { - HomeCategoryTabs( - categories = homeCategories, - selectedCategory = selectedHomeCategory, - onCategorySelected = onHomeCategorySelected, - modifier = Modifier.width(240.dp), - showHorizontalLine = false - ) - Spacer(modifier = Modifier.weight(1f)) - } SearchBar( query = "", onQueryChange = {}, @@ -280,7 +270,7 @@ private fun HomeAppBar( contentDescription = stringResource(R.string.cd_account) ) }, - modifier = if (showHomeCategoryToggle) Modifier else Modifier.fillMaxWidth() + modifier = if (isExpanded) Modifier else Modifier.fillMaxWidth() ) { } } }, @@ -309,10 +299,7 @@ private fun HomeScreen( ), topBar = { HomeAppBar( - selectedHomeCategory = homeState.selectedHomeCategory, - homeCategories = homeState.homeCategories, - onHomeCategorySelected = homeState.onHomeCategorySelected, - showHomeCategoryToggle = showGrid && homeState.showHowCategoryTabs, + isExpanded = homeState.windowSizeClass.isCompact, modifier = Modifier.fillMaxWidth(), ) }, @@ -324,7 +311,7 @@ private fun HomeScreen( val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) HomeContent( showGrid = showGrid, - showHomeCategoryTabs = homeState.showHowCategoryTabs, + showHomeCategoryTabs = homeState.showHomeCategoryTabs, featuredPodcasts = homeState.featuredPodcasts, isRefreshing = homeState.isRefreshing, selectedHomeCategory = homeState.selectedHomeCategory, @@ -387,14 +374,17 @@ private fun HomeContent( if (showGrid) { HomeContentGrid( pagerState = pagerState, + showHomeCategoryTabs = showHomeCategoryTabs, featuredPodcasts = featuredPodcasts, isRefreshing = isRefreshing, selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, filterableCategoriesModel = filterableCategoriesModel, podcastCategoryFilterResult = podcastCategoryFilterResult, library = library, modifier = modifier, onPodcastUnfollowed = onPodcastUnfollowed, + onHomeCategorySelected = onHomeCategorySelected, onCategorySelected = onCategorySelected, navigateToPodcastDetails = navigateToPodcastDetails, navigateToPlayer = navigateToPlayer, @@ -468,6 +458,7 @@ private fun HomeContentColumn( HomeCategoryTabs( categories = homeCategories, selectedCategory = selectedHomeCategory, + showHorizontalLine = true, onCategorySelected = onHomeCategorySelected ) } @@ -499,14 +490,17 @@ private fun HomeContentColumn( @Composable private fun HomeContentGrid( + showHomeCategoryTabs: Boolean, pagerState: PagerState, featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, + homeCategories: List, filterableCategoriesModel: FilterableCategoriesModel, podcastCategoryFilterResult: PodcastCategoryFilterResult, library: LibraryInfo, modifier: Modifier = Modifier, + onHomeCategorySelected: (HomeCategory) -> Unit, onPodcastUnfollowed: (PodcastInfo) -> Unit, onCategorySelected: (CategoryInfo) -> Unit, navigateToPodcastDetails: (PodcastInfo) -> Unit, @@ -535,6 +529,20 @@ private fun HomeContentGrid( // TODO show a progress indicator or similar } + if (showHomeCategoryTabs) { + fullWidthItem { + Row { + HomeCategoryTabs( + categories = homeCategories, + selectedCategory = selectedHomeCategory, + showHorizontalLine = false, + onCategorySelected = onHomeCategorySelected, + modifier = Modifier.width(240.dp) + ) + } + } + } + when (selectedHomeCategory) { HomeCategory.Library -> { libraryItems( @@ -587,8 +595,8 @@ private fun HomeCategoryTabs( categories: List, selectedCategory: HomeCategory, onCategorySelected: (HomeCategory) -> Unit, + showHorizontalLine: Boolean, modifier: Modifier = Modifier, - showHorizontalLine: Boolean = true, ) { if (categories.isEmpty()) { return @@ -752,9 +760,7 @@ private fun lastUpdated(updated: OffsetDateTime): String { private fun HomeAppBarPreview() { JetcasterTheme { HomeAppBar( - homeCategories = emptyList(), - onHomeCategorySelected = {}, - selectedHomeCategory = HomeCategory.Discover, + isExpanded = false ) } } From 4e422a65b0195ee47b451b1bfeb99d44fbef472f Mon Sep 17 00:00:00 2001 From: arriolac Date: Thu, 4 Apr 2024 22:18:03 +0000 Subject: [PATCH 096/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index bde8d3d361..9f8ab6c547 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -113,12 +113,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 data class HomeState( val windowSizeClass: WindowSizeClass, From e68bf8dbd4f985b1901a07746fdc202c317ec4c9 Mon Sep 17 00:00:00 2001 From: Jonathan Koren Date: Thu, 4 Apr 2024 15:58:02 -0700 Subject: [PATCH 097/143] Show single pane on phone in landscape --- Jetcaster/app/build.gradle.kts | 1 + .../com/example/jetcaster/ui/home/Home.kt | 79 ++++++++++++++++++- .../jetcaster/ui/home/HomeViewModel.kt | 1 + .../ui/podcast/PodcastDetailsViewModel.kt | 1 + .../jetcaster/ui/shared/EpisodeListItem.kt | 4 +- Jetcaster/gradle/libs.versions.toml | 9 +-- 6 files changed, 87 insertions(+), 8 deletions(-) diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index 80b1348d07..311ea2a67e 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -120,6 +120,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.window) + implementation(libs.androidx.window.core) implementation(libs.accompanist.adaptive) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index df4f32ce67..ce8c9f785c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -66,11 +66,19 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable @@ -82,16 +90,20 @@ 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.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.data.model.CategoryInfo @@ -142,6 +154,69 @@ private fun ThreePaneScaffoldNavigator.isMainPaneHidden(): Boolean { return scaffoldValue[SupportingPaneScaffoldRole.Main] == PaneAdaptedValue.Hidden } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +fun calculateScaffoldDirective( + windowAdaptiveInfo: WindowAdaptiveInfo, + verticalHingePolicy: HingePolicy = HingePolicy.AvoidSeparating +): PaneScaffoldDirective { + val maxHorizontalPartitions: Int + val verticalSpacerSize: Dp + if (windowAdaptiveInfo.windowSizeClass.isCompact()) { + 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) + ) +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy): List { + return when (hingePolicy) { + HingePolicy.AvoidSeparating -> posture.separatingVerticalHingeBounds + HingePolicy.AvoidOccluding -> posture.occludingVerticalHingeBounds + HingePolicy.AlwaysAvoid -> posture.allVerticalHingeBounds + else -> emptyList() + } +} + +private fun androidx.window.core.layout.WindowSizeClass.isCompact(): Boolean = + windowWidthSizeClass == WindowWidthSizeClass.COMPACT || + windowHeightSizeClass == WindowHeightSizeClass.COMPACT + @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun MainScreen( @@ -150,7 +225,9 @@ fun MainScreen( viewModel: HomeViewModel = hiltViewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() - val navigator = rememberSupportingPaneScaffoldNavigator() + val navigator = rememberSupportingPaneScaffoldNavigator( + scaffoldDirective = calculateScaffoldDirective(currentWindowAdaptiveInfo()) + ) BackHandler(enabled = navigator.canNavigateBack()) { navigator.navigateBack() } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index bfeddd3e28..b5fe0fc679 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -35,6 +35,7 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlin.text.Typography.dagger import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 22c78fa9c9..ebd5bdeb2c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -30,6 +30,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlin.text.Typography.dagger import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index 44f6217b9f..693be0136a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -30,12 +30,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -123,7 +123,7 @@ private fun EpisodeListItemFooter( modifier = Modifier .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = false, radius = 24.dp) + indication = rememberRipple(bounded = false, radius = 24.dp) ) { /* TODO */ } .size(48.dp) .padding(6.dp) diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index d22948965f..b5647a547d 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -10,9 +10,7 @@ androidx-appcompat = "1.6.1" androidx-benchmark = "1.2.3" androidx-benchmark-junit4 = "1.2.3" androidx-compose-bom = "2024.03.00" -androidx-compose-latest = "1.7.0-alpha05" -androidx-compose-material3-adaptive = "1.0.0-alpha09" -androidx-compose-material3-latest = "1.3.0-alpha03" +androidx-compose-material3-adaptive = "1.0.0-alpha10" androidx-constraintlayout = "1.0.1" androidx-corektx = "1.13.0-beta01" androidx-glance = "1.0.0" @@ -87,14 +85,14 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } -androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3-latest" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidx-compose-material3-adaptive" } androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "androidx-compose-material3-adaptive" } androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "androidx-compose-material3-adaptive" } androidx-compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } -androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-latest" } +androidx-compose-ui = { module = "androidx.compose.ui:ui"} androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } @@ -134,6 +132,7 @@ androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } +androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" } google-android-material = { module = "com.google.android.material:material", version.ref = "material" } From 11111ebd21beb7f6222da19dec795b189a6d311a Mon Sep 17 00:00:00 2001 From: jdkoren Date: Fri, 5 Apr 2024 17:17:39 +0000 Subject: [PATCH 098/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt | 1 - .../com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index b5fe0fc679..bfeddd3e28 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -35,7 +35,6 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlin.text.Typography.dagger import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index ebd5bdeb2c..22c78fa9c9 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -30,7 +30,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlin.text.Typography.dagger import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine From 71d7dce52f24b227b96b6a41091409472365bffc Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Wed, 3 Apr 2024 21:51:28 +0100 Subject: [PATCH 099/143] adds navigation to player, cleanup --- .../core/data/model/PlayerEpisode.kt | 3 + Jetcaster/wear/build.gradle | 2 + .../com/example/jetcaster/MainActivity.kt | 93 +--------- .../java/com/example/jetcaster/WearApp.kt | 115 +++++++++++++ .../example/jetcaster/ui/home/HomeScreen.kt | 159 +++++++++--------- .../jetcaster/ui/home/HomeViewModel.kt | 113 ++++++------- .../ui/library/LatestEpisodeViewModel.kt | 6 + .../ui/library/LatestEpisodesScreen.kt | 54 ++++-- .../jetcaster/ui/player/PlayerScreen.kt | 70 ++++---- .../jetcaster/ui/player/PlayerViewModel.kt | 50 ++---- 10 files changed, 349 insertions(+), 316 deletions(-) create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt index 109810e3e0..4eeca1f437 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -30,6 +30,7 @@ data class PlayerEpisode( val author: String = "", val summary: String = "", val podcastImageUrl: String = "", + val uri: String = "" ) { constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( title = episodeInfo.title, @@ -39,6 +40,7 @@ data class PlayerEpisode( author = episodeInfo.author, summary = episodeInfo.summary, podcastImageUrl = podcastInfo.imageUrl, + uri = episodeInfo.uri ) } @@ -49,4 +51,5 @@ fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = podcastName = podcast.title, summary = episode.summary ?: "", podcastImageUrl = podcast.imageUrl ?: "", + uri = episode.uri ) diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index 1cb72263a6..72cf8f8436 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -55,6 +55,8 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.majorVersion + freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn" + freeCompilerArgs = freeCompilerArgs + "-opt-in=com.google.android.horologist.annotations.ExperimentalHorologistApi" } buildFeatures { compose true diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt index 33045b249d..ac1fd8d2eb 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt @@ -14,113 +14,22 @@ * limitations under the License. */ -@file:OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class) - package com.example.jetcaster import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.material.TimeText -import androidx.wear.compose.navigation.composable -import androidx.wear.compose.navigation.rememberSwipeDismissableNavController -import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState -import com.example.jetcaster.theme.WearAppTheme -import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode -import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext -import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast -import com.example.jetcaster.ui.LatestEpisodes -import com.example.jetcaster.ui.home.HomeScreen -import com.example.jetcaster.ui.library.LatestEpisodesScreen -import com.example.jetcaster.ui.player.PlayerScreen -import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.audio.ui.VolumeViewModel -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberColumnState -import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume -import com.google.android.horologist.media.ui.navigation.MediaPlayerScaffold -import com.google.android.horologist.media.ui.snackbar.SnackbarManager -import com.google.android.horologist.media.ui.snackbar.SnackbarViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() super.onCreate(savedInstanceState) setContent { - installSplashScreen() WearApp() } } } - -@OptIn(ExperimentalHorologistApi::class) -@Composable -fun WearApp() { - - val navController = rememberSwipeDismissableNavController() - val navHostState = rememberSwipeDismissableNavHostState() - val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory) - val snackBarManager: SnackbarManager = SnackbarManager() - val snackbarViewModel: SnackbarViewModel = SnackbarViewModel(snackBarManager) - - WearAppTheme { - MediaPlayerScaffold( - playerScreen = { - PlayerScreen( - modifier = Modifier.fillMaxSize(), - volumeViewModel = volumeViewModel, - onVolumeClick = { - navController.navigateToVolume() - }, - ) - }, - libraryScreen = { - HomeScreen( - onLatestEpisodeClick = { navController.navigateToLatestEpisode() }, - onYourPodcastClick = { navController.navigateToYourPodcast() }, - onUpNextClick = { navController.navigateToUpNext() }, - onErrorDialogCancelClick = { navController.popBackStack() } - ) - }, - categoryEntityScreen = { _, _ -> }, - mediaEntityScreen = {}, - playlistsScreen = {}, - settingsScreen = {}, - - navHostState = navHostState, - snackbarViewModel = snackbarViewModel, - volumeViewModel = volumeViewModel, - timeText = { - TimeText() - }, - deepLinkPrefix = "", - navController = navController, - additionalNavRoutes = { - composable( - route = LatestEpisodes.navRoute, - ) { - val columnState = rememberColumnState() - - ScreenScaffold(scrollState = columnState) { - LatestEpisodesScreen( - columnState = columnState, - playlistName = stringResource(id = R.string.latest_episodes), - onShuffleButtonClick = {}, - onPlayButtonClick = {} - ) - } - } - }, - - ) - } -} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt new file mode 100644 index 0000000000..6869c112c6 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -0,0 +1,115 @@ +/* + * 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 + +/* + * 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 androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState +import com.example.jetcaster.theme.WearAppTheme +import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode +import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext +import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast +import com.example.jetcaster.ui.LatestEpisodes +import com.example.jetcaster.ui.home.HomeScreen +import com.example.jetcaster.ui.library.LatestEpisodesScreen +import com.example.jetcaster.ui.player.PlayerScreen +import com.google.android.horologist.audio.ui.VolumeViewModel +import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer +import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume +import com.google.android.horologist.media.ui.navigation.MediaPlayerScaffold +import com.google.android.horologist.media.ui.snackbar.SnackbarManager +import com.google.android.horologist.media.ui.snackbar.SnackbarViewModel + +@Composable +fun WearApp() { + + val navController = rememberSwipeDismissableNavController() + val navHostState = rememberSwipeDismissableNavHostState() + val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory) + + // TODO remove from MediaPlayerScaffold + val snackBarManager: SnackbarManager = SnackbarManager() + val snackbarViewModel: SnackbarViewModel = SnackbarViewModel(snackBarManager) + + WearAppTheme { + MediaPlayerScaffold( + playerScreen = { + PlayerScreen( + modifier = Modifier.fillMaxSize(), + volumeViewModel = volumeViewModel, + onVolumeClick = { + navController.navigateToVolume() + }, + ) + }, + libraryScreen = { + HomeScreen( + onLatestEpisodeClick = { navController.navigateToLatestEpisode() }, + onYourPodcastClick = { navController.navigateToYourPodcast() }, + onUpNextClick = { navController.navigateToUpNext() } + ) + }, + categoryEntityScreen = { _, _ -> }, + mediaEntityScreen = {}, + playlistsScreen = {}, + settingsScreen = {}, + + navHostState = navHostState, + snackbarViewModel = snackbarViewModel, + volumeViewModel = volumeViewModel, + deepLinkPrefix = "", + navController = navController, + additionalNavRoutes = { + composable( + route = LatestEpisodes.navRoute, + ) { + LatestEpisodesScreen( + playlistName = stringResource(id = R.string.latest_episodes), + onShuffleButtonClick = { + // navController.navigateToPlayer(it[0].episode.uri) + }, + onPlayButtonClick = { + navController.navigateToPlayer() + } + ) + } + }, + + ) + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt index 041c3355d7..bc45f60d82 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -20,6 +20,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MusicNote import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter @@ -30,7 +33,6 @@ import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Text import com.example.jetcaster.R import com.example.jetcaster.core.data.model.PodcastInfo -import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.composables.PlaceholderChip import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults @@ -44,15 +46,36 @@ import com.google.android.horologist.images.base.paintable.DrawableResPaintable import com.google.android.horologist.images.base.util.rememberVectorPainter import com.google.android.horologist.images.coil.CoilPaintable -@OptIn(ExperimentalHorologistApi::class) @Composable fun HomeScreen( onLatestEpisodeClick: () -> Unit, onYourPodcastClick: () -> Unit, onUpNextClick: () -> Unit, - onErrorDialogCancelClick: () -> Unit, modifier: Modifier = Modifier, homeViewModel: HomeViewModel = hiltViewModel(), +) { + val viewState by homeViewModel.uiState.collectAsStateWithLifecycle() + + HomeScreen( + modifier = modifier, + viewState = viewState, + onLatestEpisodeClick = onLatestEpisodeClick, + onYourPodcastClick = onYourPodcastClick, + onUpNextClick = onUpNextClick, + onTogglePodcastFollowed = { + homeViewModel.onTogglePodcastFollowed(it.uri) + }, + ) +} + +@Composable +fun HomeScreen( + viewState: HomeViewState, + onLatestEpisodeClick: () -> Unit, + onYourPodcastClick: () -> Unit, + onUpNextClick: () -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, ) { val columnState = rememberResponsiveColumnState( contentPadding = ScalingLazyColumnDefaults.padding( @@ -60,93 +83,77 @@ fun HomeScreen( last = ScalingLazyColumnDefaults.ItemType.Chip, ), ) - val viewState by homeViewModel.state.collectAsStateWithLifecycle() + var haveDismissedDialog by remember { mutableStateOf(false) } ScreenScaffold(scrollState = columnState, modifier = modifier) { ScalingLazyColumn(columnState = columnState) { - if (viewState.featuredPodcasts.isNotEmpty()) { - item { - ResponsiveListHeader(modifier = Modifier.listTextPadding()) { - Text(stringResource(R.string.home_library)) - } - } - item { - Chip( - label = stringResource(R.string.latest_episodes), - onClick = onLatestEpisodeClick, - icon = DrawableResPaintable(R.drawable.new_releases), - colors = ChipDefaults.secondaryChipColors() - ) - } - item { - Chip( - label = stringResource(R.string.podcasts), - onClick = onYourPodcastClick, - icon = DrawableResPaintable(R.drawable.podcast), - colors = ChipDefaults.secondaryChipColors() - ) + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.home_library)) } - item { - ResponsiveListHeader(modifier = Modifier.listTextPadding()) { - Text(stringResource(R.string.queue)) - } + } + item { + Chip( + label = stringResource(R.string.latest_episodes), + onClick = onLatestEpisodeClick, + icon = DrawableResPaintable(R.drawable.new_releases), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + Chip( + label = stringResource(R.string.podcasts), + onClick = onYourPodcastClick, + icon = DrawableResPaintable(R.drawable.podcast), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.queue)) } - item { - Chip( - label = stringResource(R.string.up_next), - onClick = onUpNextClick, - icon = DrawableResPaintable(R.drawable.up_next), - colors = ChipDefaults.secondaryChipColors() + } + item { + Chip( + label = stringResource(R.string.up_next), + onClick = onUpNextClick, + icon = DrawableResPaintable(R.drawable.up_next), + colors = ChipDefaults.secondaryChipColors() + ) + } + } + } + AlertDialog( + message = stringResource(R.string.entity_no_featured_podcasts), + showDialog = !haveDismissedDialog && viewState.featuredPodcasts.isEmpty(), + onDismiss = { haveDismissedDialog = true }, + + content = { + val podcast = viewState.podcastCategoryFilterResult.topPodcasts.first() + if (viewState.podcastCategoryFilterResult.topPodcasts.isNotEmpty()) { + items(viewState.podcastCategoryFilterResult.topPodcasts.take(1).size) { index -> + PodcastContent( + podcast = podcast, + downloadItemArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onClick = { + onTogglePodcastFollowed(podcast) + }, ) } } else { item { - AlertDialog( - message = stringResource(R.string.entity_no_featured_podcasts), - showDialog = true, - onDismiss = { onErrorDialogCancelClick }, - content = { - - if (viewState - .podcastCategoryFilterResult - .topPodcasts - .isNotEmpty() - ) { - item { - PodcastContent( - podcast = viewState.podcastCategoryFilterResult - .topPodcasts[0], - downloadItemArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onClick = { - homeViewModel.onTogglePodcastFollowed( - viewState - .podcastCategoryFilterResult - .topPodcasts[0] - .uri - ) - }, - ) - } - } else { - item { - PlaceholderChip( - contentDescription = "", - colors = ChipDefaults.secondaryChipColors() - ) - } - } - } + PlaceholderChip( + contentDescription = "", + colors = ChipDefaults.secondaryChipColors() ) } } } - } + ) } - -@OptIn(ExperimentalHorologistApi::class) @Composable private fun PodcastContent( podcast: PodcastInfo, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 5bed543e20..b1851dc961 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -34,14 +34,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine 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.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @@ -62,76 +60,61 @@ class HomeViewModel @Inject constructor( private val homeCategories = MutableStateFlow(HomeCategory.entries) // Holds our currently selected category private val _selectedCategory = MutableStateFlow(null) - // Holds our view state which the UI collects via [state] - private val _state = MutableStateFlow(HomeViewState()) + // Holds the view state if the UI is refreshing for new data private val refreshing = MutableStateFlow(false) - val state: StateFlow - 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. - combine( - homeCategories, - selectedHomeCategory, - podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), - refreshing, - _selectedCategory.flatMapLatest { selectedCategory -> - filterableCategoriesUseCase(selectedCategory) - }, - _selectedCategory.flatMapLatest { - podcastCategoryFilterUseCase(it) - }, - selectedLibraryPodcast.flatMapLatest { - episodeStore.episodesInPodcast( - podcastUri = it?.uri ?: "", - limit = 20 - ) - } - ) { homeCategories, - homeCategory, - podcasts, - refreshing, - filterableCategories, - podcastCategoryFilterResult, - libraryEpisodes -> - - _selectedCategory.value = filterableCategories.selectedCategory - - selectedHomeCategory.value = homeCategory - - HomeViewState( - homeCategories = homeCategories, - selectedHomeCategory = homeCategory, - featuredPodcasts = podcasts.toPersistentList(), - refreshing = refreshing, - filterableCategoriesModel = filterableCategories, - podcastCategoryFilterResult = podcastCategoryFilterResult, - libraryEpisodes = libraryEpisodes, - errorMessage = null, /* TODO */ - ) - }.catch { throwable -> - // TODO: emit a UI error here. For now we'll just rethrow - throw throwable - }.collect { - _state.value = it - } + // Combines the latest value from each of the flows, allowing us to generate a + // view state instance which only contains the latest values. + val uiState = combine( + homeCategories, + selectedHomeCategory, + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), + refreshing, + _selectedCategory.flatMapLatest { selectedCategory -> + filterableCategoriesUseCase(selectedCategory) + }, + _selectedCategory.flatMapLatest { + podcastCategoryFilterUseCase(it) + }, + selectedLibraryPodcast.flatMapLatest { + episodeStore.episodesInPodcast( + podcastUri = it?.uri ?: "", + limit = 20 + ) } + ) { homeCategories, + homeCategory, + podcasts, + refreshing, + filterableCategories, + podcastCategoryFilterResult, + libraryEpisodes -> + + _selectedCategory.value = filterableCategories.selectedCategory + + selectedHomeCategory.value = homeCategory + + HomeViewState( + homeCategories = homeCategories, + selectedHomeCategory = homeCategory, + featuredPodcasts = podcasts.toPersistentList(), + refreshing = refreshing, + filterableCategoriesModel = filterableCategories, + podcastCategoryFilterResult = podcastCategoryFilterResult, + libraryEpisodes = libraryEpisodes, + errorMessage = null, /* TODO */ + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, initialValue = HomeViewState()) + init { refresh(force = false) } private fun refresh(force: Boolean) { viewModelScope.launch { - runCatching { - refreshing.value = true - podcastsRepository.updatePodcasts(force) - } - // TODO: look at result of runCatching and show any errors - + refreshing.value = true + podcastsRepository.updatePodcasts(force) refreshing.value = false } } @@ -166,7 +149,7 @@ enum class HomeCategory { } data class HomeViewState( - val featuredPodcasts: PersistentList = persistentListOf(), + val featuredPodcasts: List = listOf(), val refreshing: Boolean = false, val selectedHomeCategory: HomeCategory = HomeCategory.Discover, val homeCategories: List = emptyList(), diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index 9a13538d06..2d17e695ea 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -31,6 +33,7 @@ import kotlinx.coroutines.launch @HiltViewModel class LatestEpisodeViewModel @Inject constructor( private val episodesFromFavouritePodcasts: GetLatestFollowedEpisodesUseCase, + private val episodePlayer: EpisodePlayer, ) : ViewModel() { // Holds our view state which the UI collects via [state] private val _state = MutableStateFlow(LatestEpisodeViewState()) @@ -64,6 +67,9 @@ class LatestEpisodeViewModel @Inject constructor( } } } + fun onPlayEpisode(episode: PlayerEpisode) { + episodePlayer.currentEpisode = episode + } } data class LatestEpisodeViewState( val refreshing: Boolean = false, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt index ba4879b2e7..58e4042aa7 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt @@ -38,9 +38,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.toPlayerEpisode import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.compose.layout.ScalingLazyColumnState import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberColumnState import com.google.android.horologist.compose.material.Button import com.google.android.horologist.compose.material.Chip import com.google.android.horologist.images.base.util.rememberVectorPainter @@ -48,23 +50,40 @@ import com.google.android.horologist.images.coil.CoilPaintable import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader import com.google.android.horologist.media.ui.screens.entity.EntityScreen -@OptIn(ExperimentalHorologistApi::class) -@Composable -public fun LatestEpisodesScreen( - columnState: ScalingLazyColumnState, +@Composable fun LatestEpisodesScreen( playlistName: String, - onShuffleButtonClick: (EpisodeToPodcast) -> Unit, - onPlayButtonClick: (EpisodeToPodcast) -> Unit, + onShuffleButtonClick: (List) -> Unit, + onPlayButtonClick: (List) -> Unit, modifier: Modifier = Modifier, latestEpisodeViewModel: LatestEpisodeViewModel = hiltViewModel() ) { val viewState by latestEpisodeViewModel.state.collectAsStateWithLifecycle() + LatestEpisodeScreen( + modifier = modifier, + playlistName = playlistName, + viewState = viewState, + onShuffleButtonClick = onShuffleButtonClick, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = latestEpisodeViewModel::onPlayEpisode + ) +} +@Composable +fun LatestEpisodeScreen( + playlistName: String, + viewState: LatestEpisodeViewState, + onShuffleButtonClick: (List) -> Unit, + onPlayButtonClick: (List) -> Unit, + modifier: Modifier = Modifier, + onPlayEpisode: (PlayerEpisode) -> Unit, +) { + val columnState = rememberColumnState() ScreenScaffold( scrollState = columnState, modifier = modifier ) { EntityScreen( + modifier = modifier, columnState = columnState, headerContent = { DefaultEntityScreenHeader(title = playlistName) }, content = { @@ -78,20 +97,20 @@ public fun LatestEpisodesScreen( ) } }, - modifier = modifier, buttonsContent = { ButtonsContent( + viewState = viewState, onShuffleButtonClick = onShuffleButtonClick, onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode ) }, ) } } -@OptIn(ExperimentalHorologistApi::class) @Composable -private fun MediaContent( +fun MediaContent( episode: EpisodeToPodcast, downloadItemArtworkPlaceholder: Painter? ) { @@ -111,9 +130,11 @@ private fun MediaContent( @OptIn(ExperimentalHorologistApi::class) @Composable -private fun ButtonsContent( - onShuffleButtonClick: (EpisodeToPodcast) -> Unit, - onPlayButtonClick: (EpisodeToPodcast) -> Unit, +fun ButtonsContent( + viewState: LatestEpisodeViewState, + onShuffleButtonClick: (List) -> Unit, + onPlayButtonClick: (List) -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit ) { Row( @@ -126,7 +147,7 @@ private fun ButtonsContent( Button( imageVector = ImageVector.vectorResource(R.drawable.speed), contentDescription = stringResource(id = R.string.speed_button_content_description), - onClick = { /*onShuffleButtonClick(state.collectionModel)*/ }, + onClick = { onShuffleButtonClick(viewState.libraryEpisodes) }, modifier = Modifier .weight(weight = 0.3F, fill = false), ) @@ -134,7 +155,10 @@ private fun ButtonsContent( Button( imageVector = Icons.Filled.PlayArrow, contentDescription = stringResource(id = R.string.button_play_content_description), - onClick = { /*onPlayButtonClick(state.)*/ }, + onClick = { + onPlayButtonClick(viewState.libraryEpisodes) + onPlayEpisode(viewState.libraryEpisodes[0].toPlayerEpisode()) + }, modifier = Modifier .weight(weight = 0.3F, fill = false), ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 92263c45bb..b0bd7b4ded 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -36,25 +36,20 @@ 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.focus.FocusRequester import androidx.compose.ui.platform.LocalView import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.foundation.rememberActiveFocusRequester import androidx.wear.compose.material.MaterialTheme -import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.audio.ui.rotaryVolumeControlsWithFocus import com.google.android.horologist.compose.rotaryinput.RotaryDefaults import com.google.android.horologist.media.ui.components.PodcastControlButtons import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground +import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay import com.google.android.horologist.media.ui.components.display.TextMediaDisplay import com.google.android.horologist.media.ui.screens.player.PlayerScreen -import com.google.android.horologist.media.ui.state.PlayerUiController -import com.google.android.horologist.media.ui.state.PlayerUiState -@OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class) @Composable fun PlayerScreen( volumeViewModel: VolumeViewModel, @@ -63,12 +58,36 @@ fun PlayerScreen( playerScreenViewModel: PlayerViewModel = hiltViewModel(), ) { val volumeUiState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle() - // val settingsState by playerScreenViewModel.settingsState.collectAsStateWithLifecycle() - val focusRequester: FocusRequester = rememberActiveFocusRequester() + val playerUiState by playerScreenViewModel.uiState.collectAsStateWithLifecycle() + + PlayerScreen( + modifier = modifier, + playerUiState = playerUiState, + volumeUiState = volumeUiState, + onVolumeClick = onVolumeClick, + onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, + ) +} + +@Composable +private fun PlayerScreen( + playerUiState: PlayerUiState?, + volumeUiState: VolumeUiState, + onVolumeClick: () -> Unit, + onUpdateVolume: (Int) -> Unit, + modifier: Modifier = Modifier, +) { PlayerScreen( mediaDisplay = { - playerScreenViewModel.uiState?.let { - TextMediaDisplay(title = it.podcastName, subtitle = it.subTitle) + if (playerUiState != null) { + playerUiState.episodePlayerState.currentEpisode?.let { + TextMediaDisplay( + title = it.podcastName, + subtitle = it.title + ) + } + } else { + LoadingMediaDisplay() } }, @@ -92,31 +111,20 @@ fun PlayerScreen( ) }, modifier = modifier.rotaryVolumeControlsWithFocus( - focusRequester = focusRequester, volumeUiStateProvider = { volumeUiState }, - onRotaryVolumeInput = { newVolume -> volumeViewModel.setVolume(newVolume) }, + onRotaryVolumeInput = onUpdateVolume, localView = LocalView.current, isLowRes = RotaryDefaults.isLowResInput(), ), background = { - val artworkUri = playerScreenViewModel.uiState.podcastImageUrl - ArtworkColorBackground( - artworkUri = artworkUri, - defaultColor = MaterialTheme.colors.primary, - modifier = Modifier.fillMaxSize(), - ) + if (playerUiState != null) { + val artworkUri = playerUiState.episodePlayerState.currentEpisode?.podcastImageUrl + ArtworkColorBackground( + artworkUri = artworkUri, + defaultColor = MaterialTheme.colors.primary, + modifier = Modifier.fillMaxSize(), + ) + } } ) } - -@OptIn(ExperimentalHorologistApi::class) -@Composable -fun PlayerScreenPodcastControlButtons( - playerUiController: PlayerUiController, - playerUiState: PlayerUiState, -) { - PodcastControlButtons( - playerController = playerUiController, - playerUiState = playerUiState, - ) -} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 723b4a105e..d21ea47ce4 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -16,29 +16,18 @@ 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.data.repository.PodcastStore +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.EpisodePlayerState import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.Duration import javax.inject.Inject -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch data class PlayerUiState( - val title: String = "", - val subTitle: String = "", - val duration: Duration? = null, - val podcastName: String = "", - val author: String = "", - val summary: String = "", - val podcastImageUrl: String = "" + val episodePlayerState: EpisodePlayerState = EpisodePlayerState() ) /** @@ -46,34 +35,21 @@ data class PlayerUiState( */ @HiltViewModel class PlayerViewModel @Inject constructor( - episodeStore: EpisodeStore, - podcastStore: PodcastStore, - savedStateHandle: SavedStateHandle + private val episodePlayer: EpisodePlayer, ) : ViewModel() { - var uiState by mutableStateOf(PlayerUiState()) - private set + val uiState = MutableStateFlow(null) init { viewModelScope.launch { - if (savedStateHandle.get("episodeUri") != null) { - val episodeUri = Uri.decode(savedStateHandle.get("episodeUri")) - val episode = episodeStore.episodeWithUri(episodeUri).first() - val podcast = podcastStore.podcastWithUri(episode.podcastUri).first() - uiState = PlayerUiState( - title = episode.title, - duration = episode.duration, - podcastName = podcast.title, - summary = episode.summary ?: "", - podcastImageUrl = podcast.imageUrl ?: "" + val currentEpisode = episodePlayer.currentEpisode + if (currentEpisode != null) { + uiState.value = PlayerUiState( + episodePlayer.playerState.value ) } else { - uiState = PlayerUiState( - title = "", - duration = Duration.ZERO, - podcastName = "Nothing to play", - summary = "", - podcastImageUrl = "" + uiState.value = PlayerUiState( + EpisodePlayerState(currentEpisode = PlayerEpisode(title = "Nothing playing")) ) } } From 628649528469846b12d22f59dd2af178359f46e0 Mon Sep 17 00:00:00 2001 From: Jonathan Koren Date: Mon, 8 Apr 2024 09:44:56 -0700 Subject: [PATCH 100/143] Explanatory comments --- .../src/main/java/com/example/jetcaster/ui/home/Home.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index ce8c9f785c..c2057df373 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -154,6 +154,10 @@ private fun ThreePaneScaffoldNavigator.isMainPaneHidden(): Boolean { return 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. + */ @OptIn(ExperimentalMaterial3AdaptiveApi::class) fun calculateScaffoldDirective( windowAdaptiveInfo: WindowAdaptiveInfo, @@ -162,6 +166,7 @@ fun calculateScaffoldDirective( 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 { @@ -203,6 +208,9 @@ fun calculateScaffoldDirective( ) } +/** + * Copied from `getExcludedVerticalBounds()` in [PaneScaffoldDirective] since it is private. + */ @OptIn(ExperimentalMaterial3AdaptiveApi::class) private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy): List { return when (hingePolicy) { From d0b64d71e6c07fbfac9302a644d6a9e11c7eb702 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 8 Apr 2024 15:51:47 -0700 Subject: [PATCH 101/143] [Jetcaster]: Fix ambiguous WSC import. --- .../main/java/com/example/jetcaster/ui/home/Home.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index ed8c55e4f9..a83666ae55 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -106,7 +106,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowHeightSizeClass -import androidx.window.core.layout.WindowWidthSizeClass import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.data.model.CategoryInfo @@ -125,12 +124,12 @@ 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 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime data class HomeState( val windowSizeClass: WindowSizeClass, @@ -186,11 +185,11 @@ fun calculateScaffoldDirective( verticalSpacerSize = 0.dp } else { when (windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass) { - WindowWidthSizeClass.COMPACT -> { + androidx.window.core.layout.WindowWidthSizeClass.COMPACT -> { maxHorizontalPartitions = 1 verticalSpacerSize = 0.dp } - WindowWidthSizeClass.MEDIUM -> { + androidx.window.core.layout.WindowWidthSizeClass.MEDIUM -> { maxHorizontalPartitions = 1 verticalSpacerSize = 0.dp } @@ -237,7 +236,7 @@ private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy } private fun androidx.window.core.layout.WindowSizeClass.isCompact(): Boolean = - windowWidthSizeClass == WindowWidthSizeClass.COMPACT || + windowWidthSizeClass == androidx.window.core.layout.WindowWidthSizeClass.COMPACT || windowHeightSizeClass == WindowHeightSizeClass.COMPACT @OptIn(ExperimentalMaterial3AdaptiveApi::class) From 5379e1d62678ae8c940c191d1a25818961bd81db Mon Sep 17 00:00:00 2001 From: arriolac Date: Mon, 8 Apr 2024 22:54:19 +0000 Subject: [PATCH 102/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index a83666ae55..d84ef9bf58 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -124,12 +124,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 data class HomeState( val windowSizeClass: WindowSizeClass, From 818b8fca1d7a6a94d6d8f66cd74ef0324e5350a2 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Fri, 5 Apr 2024 14:28:48 +0900 Subject: [PATCH 103/143] Remove experimental annotations --- .../src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index c9da96f1e8..262bd34426 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -27,7 +27,6 @@ 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.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -46,7 +45,6 @@ import com.example.jetcaster.tv.ui.search.SearchScreen import com.example.jetcaster.tv.ui.settings.SettingsScreen import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults -@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) { Route(jetcasterAppState = jetcasterAppState) From 8899463fe5b8f9d734aa39c3ba9e21a2995dc13e Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Fri, 5 Apr 2024 14:29:39 +0900 Subject: [PATCH 104/143] Update parameter retrieval code to use flow --- .../tv/ui/episode/EpisodeScreenViewModel.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index 8518074e29..fc609243e3 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -25,12 +25,14 @@ import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastsRepository 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 +import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( @@ -39,12 +41,15 @@ class EpisodeScreenViewModel @Inject constructor( episodeStore: EpisodeStore, ) : ViewModel() { - private val episodeUri = handle.get(Screen.Episode.PARAMETER_NAME) + private val episodeUriFlow = handle.getStateFlow(Screen.Episode.PARAMETER_NAME, null) - private val episodeToPodcastFlow = if (episodeUri != null) { - episodeStore.episodeAndPodcastWithUri(episodeUri) - } else { - flowOf(null) + @OptIn(ExperimentalCoroutinesApi::class) + private val episodeToPodcastFlow = episodeUriFlow.flatMapLatest { + if (it != null) { + episodeStore.episodeAndPodcastWithUri(it) + } else { + flowOf(null) + } }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5_000), From 9f999d22ba7793d0b874dd408acd85cb865ce890 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Mon, 8 Apr 2024 17:51:02 +0900 Subject: [PATCH 105/143] Add the player screen to TV app --- .../core/data/model/PlayerEpisode.kt | 8 + .../jetcaster/core/player/EpisodePlayer.kt | 5 + .../core/player/MockEpisodePlayer.kt | 27 + .../example/jetcaster/tv/model/EpisodeList.kt | 6 + .../example/jetcaster/tv/ui/JetcasterApp.kt | 14 +- .../jetcaster/tv/ui/JetcasterAppState.kt | 25 +- .../jetcaster/tv/ui/component/Background.kt | 56 +- .../jetcaster/tv/ui/component/Button.kt | 94 ++++ .../jetcaster/tv/ui/component/Catalog.kt | 70 +-- .../jetcaster/tv/ui/component/EpisodeCard.kt | 116 ++++ .../tv/ui/component/EpisodeDetails.kt | 81 +++ .../jetcaster/tv/ui/component/Seekbar.kt | 59 +++ .../jetcaster/tv/ui/component/Thumbnail.kt | 41 +- .../jetcaster/tv/ui/component/TwoColumn.kt | 41 ++ .../jetcaster/tv/ui/episode/EpisodeScreen.kt | 54 +- .../tv/ui/episode/EpisodeScreenViewModel.kt | 11 +- .../jetcaster/tv/ui/player/PlayerScreen.kt | 496 ++++++++++++++++++ .../tv/ui/player/PlayerScreenViewModel.kt | 83 +++ .../example/jetcaster/tv/ui/theme/Space.kt | 43 +- .../tv-app/src/main/res/values/strings.xml | 4 + 20 files changed, 1216 insertions(+), 118 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt index 4eeca1f437..77a6d47d82 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -18,13 +18,16 @@ package com.example.jetcaster.core.data.model import com.example.jetcaster.core.data.database.model.EpisodeToPodcast 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 = "", @@ -35,6 +38,7 @@ data class PlayerEpisode( constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( title = episodeInfo.title, subTitle = episodeInfo.subTitle, + published = episodeInfo.published, duration = episodeInfo.duration, podcastName = podcastInfo.title, author = episodeInfo.author, @@ -46,9 +50,13 @@ data class PlayerEpisode( 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 ?: "", uri = episode.uri diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt index 73b275dfb6..648e10ec6b 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -50,6 +50,11 @@ interface EpisodePlayer { */ fun play() + /** + * Plays the specified episode + */ + fun play(playerEpisode: PlayerEpisode) + /** * Pauses the currently played episode */ diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 34662b24da..72c857eec3 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -44,6 +44,7 @@ class MockEpisodePlayer( private val coroutineScope = CoroutineScope(mainDispatcher) private var timerJob: Job? = null + init { coroutineScope.launch { // Combine streams here @@ -103,6 +104,32 @@ class MockEpisodePlayer( } } + override fun play(playerEpisode: PlayerEpisode) { + if (isPlaying.value) { + pause() + } + + // Keep the currently playing episode in the queue + val playingEpisode = _currentEpisode.value + queue.update { + val previousList = if (it.contains(playerEpisode)) { + val mutableList = it.toMutableList() + mutableList.remove(playerEpisode) + mutableList + } else { + it + } + if (playingEpisode != null) { + listOf(playerEpisode, playingEpisode) + previousList + } else { + listOf(playerEpisode) + previousList + } + } + + next() + play() + } + override fun pause() { isPlaying.value = false diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt index 150a0de1b5..96bf5b25bd 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -18,5 +18,11 @@ package com.example.jetcaster.tv.model import androidx.compose.runtime.Immutable import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.PlayerEpisode + @Immutable data class EpisodeList(val member: List) : List by member + +// ToDo: merge into EpisodeList as PlayerEpisode is the exposed data structure of EpisodeToPodcast +@Immutable +data class PlayerEpisodeList(val member: List) : List by member diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index 262bd34426..4cce2eb618 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -39,6 +39,7 @@ 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.PodcastScreen import com.example.jetcaster.tv.ui.profile.ProfileScreen import com.example.jetcaster.tv.ui.search.SearchScreen @@ -162,7 +163,8 @@ private fun Route(jetcasterAppState: JetcasterAppState) { composable(Screen.Podcast.route) { PodcastScreen( backToHomeScreen = jetcasterAppState::navigateToDiscover, - playEpisode = {}, + playEpisode = { + }, showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.episode.uri) }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) @@ -173,14 +175,18 @@ private fun Route(jetcasterAppState: JetcasterAppState) { composable(Screen.Episode.route) { EpisodeScreen( playEpisode = { - jetcasterAppState.playEpisode(it.uri) + jetcasterAppState.playEpisode() }, - backToHome = jetcasterAppState::navigateToDiscover, + backToHome = jetcasterAppState::backToHome, ) } composable(Screen.Player.route) { - Text(text = "Player") + PlayerScreen( + backToHome = jetcasterAppState::backToHome, + modifier = Modifier.fillMaxSize(), + showDetails = jetcasterAppState::showEpisodeDetails, + ) } composable(Screen.Profile.route) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt index a4153a8335..120e801a84 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import com.example.jetcaster.core.data.model.PlayerEpisode class JetcasterAppState( val navHostController: NavHostController @@ -57,9 +58,17 @@ class JetcasterAppState( navHostController.navigate(screen.route) } - fun playEpisode(episodeUri: String) { - val screen = Screen.Player(episodeUri) - navHostController.navigate(screen.route) + fun showEpisodeDetails(playerEpisode: PlayerEpisode) { + showEpisodeDetails(playerEpisode.uri) + } + + fun playEpisode() { + navHostController.navigate(Screen.Player.route) + } + + fun backToHome() { + navHostController.popBackStack() + navigateToDiscover() } fun navigateBack() { @@ -118,13 +127,7 @@ sealed interface Screen { } } - data class Player(private val episodeUri: String) : Screen { - override val route = "$ROOT/$episodeUri" - - companion object : Screen { - private const val ROOT = "player" - const val PARAMETER_NAME = "episodeUri" - override val route = "$ROOT/{$PARAMETER_NAME}" - } + data object Player : Screen { + override val route = "player" } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt index 9c339d2cff..5fe9e5e655 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt @@ -16,8 +16,12 @@ 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.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset @@ -28,6 +32,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.model.PlayerEpisode @Composable internal fun Background( @@ -41,9 +46,37 @@ internal fun Background( ) drawRect(brush, blendMode = BlendMode.Multiply) } +) = Background(imageUrl = podcast.imageUrl, modifier, overlay) + +@Composable +internal fun Background( + episode: PlayerEpisode, + modifier: Modifier = Modifier, + overlay: DrawScope.() -> Unit = { + val brush = Brush.radialGradient( + listOf(Color.Black, Color.Transparent), + center = Offset(0f, size.height), + radius = size.width * 1.5f + ) + drawRect(brush, blendMode = BlendMode.Multiply) + } +) = Background(imageUrl = episode.podcastImageUrl, modifier, overlay) + +@Composable +internal fun Background( + imageUrl: String?, + modifier: Modifier = Modifier, + overlay: DrawScope.() -> Unit = { + val brush = Brush.radialGradient( + listOf(Color.Black, Color.Transparent), + center = Offset(0f, size.height), + radius = size.width * 1.5f + ) + drawRect(brush, blendMode = BlendMode.Multiply) + } ) { AsyncImage( - model = podcast.imageUrl, + model = imageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = modifier @@ -56,3 +89,24 @@ internal fun Background( } ) } + +@Composable +internal fun WithBackground( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + overlay: DrawScope.() -> Unit = { + val brush = Brush.radialGradient( + listOf(Color.Black, Color.Transparent), + center = Offset(0f, size.height), + radius = size.width * 1.5f + ) + drawRect(brush, blendMode = BlendMode.Multiply) + }, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit +) { + Box(modifier = modifier, contentAlignment = contentAlignment) { + Background(episode = playerEpisode, overlay = overlay, modifier = Modifier.fillMaxSize()) + content() + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt index aef3c09787..5a29ba9fd1 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt @@ -20,6 +20,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width 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 @@ -60,6 +67,20 @@ internal fun EnqueueButton( } } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun InfoButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource(R.string.label_info), + ) + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable internal fun ButtonWithIcon( @@ -78,3 +99,76 @@ internal fun ButtonWithIcon( ) Text(text = label) } + +@OptIn(ExperimentalTvMaterial3Api::class) +@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) + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@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) + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@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) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun RewindButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.Default.Replay10, + contentDescription = stringResource(R.string.label_rewind) + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun SkipButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.Default.Forward10, + contentDescription = stringResource(R.string.label_skip) + ) + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index b9b90a84d3..3db298ce72 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -16,22 +16,17 @@ package com.example.jetcaster.tv.ui.component -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width 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.focusRestorer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.TvLazyListState @@ -44,7 +39,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.StandardCardLayout import androidx.tv.material3.Text -import androidx.tv.material3.WideCardLayout import coil.compose.AsyncImage import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.Podcast @@ -217,70 +211,8 @@ private fun EpisodeRow( EpisodeCard( episode = it, onClick = { onEpisodeSelected(it) }, - modifier = Modifier.width(JetcasterAppDefaults.cardWidth.small) + cardWidth = JetcasterAppDefaults.cardWidth.small ) } } } - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeCard( - episode: EpisodeToPodcast, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - WideCardLayout( - imageCard = { - EpisodeThumbnail(episode = episode, onClick = onClick, modifier = modifier) - }, - title = { - EpisodeMetaData( - episode = episode, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) - .width(JetcasterAppDefaults.cardWidth.small * 2) - ) - }, - ) -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeThumbnail( - episode: EpisodeToPodcast, - onClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } -) { - Card( - onClick = onClick, - interactionSource = interactionSource, - scale = CardScale.None, - modifier = modifier, - ) { - AsyncImage(model = episode.podcast.imageUrl, contentDescription = null) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modifier) { - val publishedDate = episode.episode.published - val duration = episode.episode.duration - Column(modifier = modifier) { - Text( - text = episode.episode.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) - if (duration != null) { - Spacer( - modifier = Modifier.height(JetcasterAppDefaults.gap.podcastRow * 0.8f) - ) - EpisodeDataAndDuration(offsetDateTime = publishedDate, duration = duration) - } - } -} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt new file mode 100644 index 0000000000..155dde6895 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt @@ -0,0 +1,116 @@ +/* + * 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.width +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.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.WideCardLayout +import coil.compose.AsyncImage +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun EpisodeCard( + episode: EpisodeToPodcast, + onClick: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = JetcasterAppDefaults.cardWidth.small, +) = + EpisodeCard(episode.toPlayerEpisode(), onClick, modifier, cardWidth) + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun EpisodeCard( + playerEpisode: PlayerEpisode, + onClick: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = JetcasterAppDefaults.cardWidth.small, +) { + WideCardLayout( + imageCard = { + EpisodeThumbnail(playerEpisode, onClick = onClick, modifier = Modifier.width(cardWidth)) + }, + title = { + EpisodeMetaData( + playerEpisode = playerEpisode, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .width(JetcasterAppDefaults.cardWidth.small * 2) + ) + }, + modifier = modifier + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeThumbnail( + playerEpisode: PlayerEpisode, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + Card( + onClick = onClick, + interactionSource = interactionSource, + scale = CardScale.None, + modifier = modifier, + ) { + AsyncImage(model = playerEpisode.podcastImageUrl, contentDescription = null) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@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) + } + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt new file mode 100644 index 0000000000..a646766d17 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt @@ -0,0 +1,81 @@ +/* + * 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.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.data.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.episode + ) + }, + second = { + Column( + modifier = modifier, + verticalArrangement = verticalArrangement + ) { + EpisodeAuthor(playerEpisode = playerEpisode) + EpisodeTitle(playerEpisode = playerEpisode) + content() + if (controls != null) { + controls() + } + } + } + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun EpisodeAuthor( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall +) { + Text(text = playerEpisode.author, modifier = modifier, style = style) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun EpisodeTitle( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.headlineLarge +) { + Text(text = playerEpisode.title, modifier = modifier, style = style) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt new file mode 100644 index 0000000000..6a5f73edf0 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt @@ -0,0 +1,59 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import java.time.Duration + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun Seekbar( + timeElapsed: Duration, + length: Duration, + modifier: Modifier = Modifier, + knobSize: Dp = 8.dp +) { + val color = SolidColor(MaterialTheme.colorScheme.onSurface) + 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( + color, start, end, + ) + drawCircle(color, knobRadius, knobCenter) + } + } + ) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt index 1f74b61aa9..a64f32141c 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable @@ -38,9 +39,47 @@ fun Thumbnail( JetcasterAppDefaults.cardWidth.medium ), contentScale: ContentScale = ContentScale.Crop +) = + Thumbnail( + podcast.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 ) = AsyncImage( - model = podcast.imageUrl, + model = url, contentDescription = null, contentScale = contentScale, modifier = Modifier diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt new file mode 100644 index 0000000000..94658ad170 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt @@ -0,0 +1,41 @@ +/* + * 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() + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt index a24780dda7..533e23d8ea 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -36,6 +36,8 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.core.data.database.model.Episode import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.toPlayerEpisode import com.example.jetcaster.tv.ui.component.Background import com.example.jetcaster.tv.ui.component.EnqueueButton import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration @@ -43,11 +45,12 @@ 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: (Episode) -> Unit, + playEpisode: () -> Unit, backToHome: () -> Unit, modifier: Modifier = Modifier, episodeScreenViewModel: EpisodeScreenViewModel = hiltViewModel() @@ -60,7 +63,10 @@ fun EpisodeScreen( EpisodeScreenUiState.Error -> ErrorState(backToHome = backToHome, modifier = modifier) is EpisodeScreenUiState.Ready -> EpisodeDetailsWithBackground( episodeToPodcast = s.episodeToPodcast, - playEpisode = playEpisode, + playEpisode = { + episodeScreenViewModel.play(it) + playEpisode() + }, addPlayList = episodeScreenViewModel::addPlayList ) } @@ -69,8 +75,8 @@ fun EpisodeScreen( @Composable private fun EpisodeDetailsWithBackground( episodeToPodcast: EpisodeToPodcast, - playEpisode: (Episode) -> Unit, - addPlayList: (Episode) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + addPlayList: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier, contentAlignment = Alignment.Center) { @@ -88,33 +94,35 @@ private fun EpisodeDetailsWithBackground( @Composable private fun EpisodeDetails( episodeToPodcast: EpisodeToPodcast, - playEpisode: (Episode) -> Unit, - addPlayList: (Episode) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + addPlayList: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, ) { - Row( + TwoColumn( + first = { + Thumbnail( + podcast = episodeToPodcast.podcast, + size = JetcasterAppDefaults.thumbnailSize.episode + ) + }, + second = { + EpisodeInfo( + episode = episodeToPodcast.episode, + playEpisode = { playEpisode(episodeToPodcast.toPlayerEpisode()) }, + addPlayList = { addPlayList(episodeToPodcast.toPlayerEpisode()) }, + modifier = Modifier.weight(1f) + ) + }, modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn), - ) { - Thumbnail( - podcast = episodeToPodcast.podcast, - size = JetcasterAppDefaults.thumbnailSize.episode - ) - EpisodeInfo( - episode = episodeToPodcast.episode, - playEpisode = playEpisode, - addPlayList = addPlayList, - modifier = Modifier.weight(1f) - ) - } + ) } @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun EpisodeInfo( episode: Episode, - playEpisode: (Episode) -> Unit, - addPlayList: (Episode) -> Unit, + playEpisode: () -> Unit, + addPlayList: () -> Unit, modifier: Modifier = Modifier ) { val author = episode.author @@ -134,7 +142,7 @@ private fun EpisodeInfo( Text(text = summary, softWrap = true, maxLines = 5, overflow = TextOverflow.Ellipsis) } Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) - Controls(playEpisode = { playEpisode(episode) }, addPlayList = { addPlayList(episode) }) + Controls(playEpisode = playEpisode, addPlayList = addPlayList) } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index fc609243e3..6d794c63b4 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -19,10 +19,11 @@ 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.database.model.Episode import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.PlayerEpisode 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.tv.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -39,6 +40,7 @@ class EpisodeScreenViewModel @Inject constructor( handle: SavedStateHandle, podcastsRepository: PodcastsRepository, episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, ) : ViewModel() { private val episodeUriFlow = handle.getStateFlow(Screen.Episode.PARAMETER_NAME, null) @@ -68,7 +70,12 @@ class EpisodeScreenViewModel @Inject constructor( EpisodeScreenUiState.Loading ) - fun addPlayList(episode: Episode) { + fun addPlayList(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) } init { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt new file mode 100644 index 0000000000..96696a47aa --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -0,0 +1,496 @@ +/* + * 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.compose.foundation.ExperimentalFoundationApi +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.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.ExperimentalComposeUiApi +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.focusRestorer +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.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.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.itemsIndexed +import androidx.tv.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.PlayerEpisodeList +import com.example.jetcaster.tv.ui.component.EnqueueButton +import com.example.jetcaster.tv.ui.component.EpisodeCard +import com.example.jetcaster.tv.ui.component.EpisodeDetails +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.component.WithBackground +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, + ) + } + } +} + +@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) { + EpisodePlayerWithBackground( + playerEpisode = currentEpisode, + queue = PlayerEpisodeList(episodePlayerState.queue), + isPlaying = episodePlayerState.isPlaying, + timeElapsed = episodePlayerState.timeElapsed, + play = play, + pause = pause, + previous = previous, + next = next, + skip = skip, + rewind = rewind, + enqueue = enqueue, + showDetails = showDetails, + playEpisode = playEpisode, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun EpisodePlayerWithBackground( + playerEpisode: PlayerEpisode, + queue: PlayerEpisodeList, + 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 +) { + WithBackground( + 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, + 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(), +) { + 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 + ) + } +} + +@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()) + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@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, +) { + Column( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + modifier = modifier, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + JetcasterAppDefaults.gap.default, + Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically, + ) { + 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()) + ) + 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) + } + } +} + +@Composable +private fun ElapsedTimeIndicator( + timeElapsed: Duration, + length: Duration, + 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, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@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 + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@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 PlayerQueue( + playerEpisodeList: PlayerEpisodeList, + onSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + contentPadding: PaddingValues = PaddingValues(), + focusRequester: FocusRequester = remember { FocusRequester() } +) { + TvLazyRow( + modifier = modifier, + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + ) { + itemsIndexed(playerEpisodeList) { index, item -> + val cardModifier = if (index == 0) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + } + EpisodeCard( + playerEpisode = item, + onClick = { onSelected(item) }, + modifier = cardModifier + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PlayerQueueOverlay( + playerEpisodeList: PlayerEpisodeList, + 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, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + var hasFocus by remember { mutableStateOf(false) } + val actualOffset = if (hasFocus) { + DpOffset.Zero + } else { + offset + } + Box( + modifier = modifier.drawWithCache { + onDrawBehind { + if (hasFocus) { + scrim() + } + } + }, + contentAlignment = contentAlignment, + ) { + PlayerQueue( + playerEpisodeList = playerEpisodeList, + onSelected = onSelected, + horizontalArrangement = horizontalArrangement, + contentPadding = contentPadding, + modifier = Modifier + .offset(actualOffset.x, actualOffset.y) + .focusRestorer { focusRequester } + .onFocusChanged { hasFocus = it.hasFocus }, + focusRequester = focusRequester + ) + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt new file mode 100644 index 0000000000..f8f8636b34 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt @@ -0,0 +1,83 @@ +/* + * 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.data.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.EpisodePlayerState +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 +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt index fda021d68c..9e9f3edfc9 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -27,6 +27,7 @@ internal data object JetcasterAppDefaults { val cardWidth = CardWidth() val padding = PaddingSettings() val thumbnailSize = ThumbnailSize() + val iconButtonSize: IconButtonSize = IconButtonSize() } internal data class OverScanMarginSettings( @@ -40,6 +41,12 @@ internal data class OverScanMarginSettings( 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( @@ -69,11 +76,33 @@ internal data class PaddingSettings( ) internal data class GapSettings( - val chip: Dp = 8.dp, - val episodeRow: Dp = 20.dp, - val item: Dp = 16.dp, - val paragraph: Dp = 16.dp, - val podcastRow: Dp = 20.dp, - val section: Dp = 40.dp, - val twoColumn: Dp = 40.dp, + 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) + } +} diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 8baaeeac10..865eeff288 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -21,6 +21,8 @@ Let\'s discover the podcasts! You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them! Something wrong happened + No episode in the queue + Discover the Podcast you want to listen to Podcast Latest Episodes Subscribe @@ -53,4 +55,6 @@ Updated today %1$s • %2$d mins + %1$s • %2$s + %1$02d:%2$02d \ No newline at end of file From 5662615a5dd5e20b04bbc2e6be9e6fa51a3f4d84 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 9 Apr 2024 10:08:48 +0900 Subject: [PATCH 106/143] Updated some components to be consistent with the naming convention. --- .../java/com/example/jetcaster/tv/ui/component/Background.kt | 2 +- .../java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt index 5fe9e5e655..3f2264b332 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt @@ -91,7 +91,7 @@ internal fun Background( } @Composable -internal fun WithBackground( +internal fun BackgroundContainer( playerEpisode: PlayerEpisode, modifier: Modifier = Modifier, overlay: DrawScope.() -> Unit = { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index 96696a47aa..6c5748f4d3 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -78,7 +78,7 @@ 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.component.WithBackground +import com.example.jetcaster.tv.ui.component.BackgroundContainer import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults import java.time.Duration import kotlinx.coroutines.CoroutineScope @@ -177,7 +177,7 @@ private fun EpisodePlayerWithBackground( playEpisode: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier ) { - WithBackground( + BackgroundContainer( playerEpisode = playerEpisode, modifier = modifier, contentAlignment = Alignment.Center From b683f56cdd1c6da7cf987f3c31cac6290959d999 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 9 Apr 2024 10:15:07 +0900 Subject: [PATCH 107/143] Fix format issues with spotlessApply --- .../example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt | 2 +- .../java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index 6d794c63b4..aa4bc24576 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -26,6 +26,7 @@ import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.core.player.EpisodePlayer 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 @@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index 6c5748f4d3..e215d47952 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -67,6 +67,7 @@ import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.PlayerEpisodeList +import com.example.jetcaster.tv.ui.component.BackgroundContainer import com.example.jetcaster.tv.ui.component.EnqueueButton import com.example.jetcaster.tv.ui.component.EpisodeCard import com.example.jetcaster.tv.ui.component.EpisodeDetails @@ -78,7 +79,6 @@ 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.component.BackgroundContainer import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults import java.time.Duration import kotlinx.coroutines.CoroutineScope From f06acaa11705369a6439bba2a39df12cb78fe2dd Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 9 Apr 2024 10:47:33 +0900 Subject: [PATCH 108/143] Remove duplicated attributes and parameters. --- .../java/com/example/jetcaster/core/data/model/PlayerEpisode.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt index 77a6d47d82..e7305e5b5e 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -33,7 +33,6 @@ data class PlayerEpisode( val author: String = "", val summary: String = "", val podcastImageUrl: String = "", - val uri: String = "" ) { constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( title = episodeInfo.title, @@ -59,5 +58,4 @@ fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = author = episode.author ?: podcast.author ?: "", summary = episode.summary ?: "", podcastImageUrl = podcast.imageUrl ?: "", - uri = episode.uri ) From 5079db097336bf32ecd4fb2b06cbae4b02fc53da Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 9 Apr 2024 10:48:43 +0900 Subject: [PATCH 109/143] Add @Transaction annotation to fix the failure on :core:DebugKotlin --- .../example/jetcaster/core/data/database/dao/EpisodesDao.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt index 23456f773b..e1d60d5f07 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt @@ -36,6 +36,7 @@ abstract class EpisodesDao : BaseDao { ) abstract fun episode(uri: String): Flow + @Transaction @Query( """ SELECT episodes.* FROM episodes @@ -45,6 +46,7 @@ abstract class EpisodesDao : BaseDao { ) abstract fun episodeAndPodcast(episodeUri: String): Flow + @Transaction @Query( """ SELECT * FROM episodes WHERE podcast_uri = :podcastUri @@ -75,6 +77,7 @@ abstract class EpisodesDao : BaseDao { @Query("SELECT COUNT(*) FROM episodes") abstract suspend fun count(): Int + @Transaction @Query( """ SELECT * FROM episodes WHERE podcast_uri IN (:podcastUris) From 56793d48bebc9a333e252f32f1854e4b2b7845b9 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 9 Apr 2024 13:42:12 +0900 Subject: [PATCH 110/143] Add androidx.lifecycle:lifecycle-runtime-compose to the dependency list --- Jetcaster/tv-app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetcaster/tv-app/build.gradle.kts b/Jetcaster/tv-app/build.gradle.kts index 36045066e3..c94a090d89 100644 --- a/Jetcaster/tv-app/build.gradle.kts +++ b/Jetcaster/tv-app/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { implementation(libs.androidx.tv.foundation) 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) implementation(libs.coil.kt.compose) @@ -85,7 +86,6 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) - implementation(project(":core")) implementation(project(":designsystem")) From f9b345ba3bd0c476ae3f1b76f3034ce36328c193 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Mon, 8 Apr 2024 15:14:32 +0100 Subject: [PATCH 111/143] Enable play/pause on the Player --- .../example/jetcaster/ui/home/HomeScreen.kt | 4 +- .../ui/library/LatestEpisodeViewModel.kt | 1 + .../jetcaster/ui/player/PlayerScreen.kt | 64 +++++++++++++------ .../jetcaster/ui/player/PlayerViewModel.kt | 60 +++++++++++------ .../wear/src/main/res/values/strings.xml | 1 + 5 files changed, 89 insertions(+), 41 deletions(-) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt index bc45f60d82..8150fd77af 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -129,9 +129,9 @@ fun HomeScreen( onDismiss = { haveDismissedDialog = true }, content = { - val podcast = viewState.podcastCategoryFilterResult.topPodcasts.first() if (viewState.podcastCategoryFilterResult.topPodcasts.isNotEmpty()) { - items(viewState.podcastCategoryFilterResult.topPodcasts.take(1).size) { index -> + val podcast = viewState.podcastCategoryFilterResult.topPodcasts.first() + items(viewState.podcastCategoryFilterResult.topPodcasts.take(1).size) { PodcastContent( podcast = podcast, downloadItemArtworkPlaceholder = rememberVectorPainter( diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index 2d17e695ea..c270b83c40 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -69,6 +69,7 @@ class LatestEpisodeViewModel @Inject constructor( } fun onPlayEpisode(episode: PlayerEpisode) { episodePlayer.currentEpisode = episode + episodePlayer.play() } } data class LatestEpisodeViewState( diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index b0bd7b4ded..93d470867a 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -37,16 +37,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.MaterialTheme +import com.example.jetcaster.R import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.audio.ui.rotaryVolumeControlsWithFocus import com.google.android.horologist.compose.rotaryinput.RotaryDefaults import com.google.android.horologist.media.ui.components.PodcastControlButtons import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground -import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay +import com.google.android.horologist.media.ui.components.controls.SeekButtonIncrement import com.google.android.horologist.media.ui.components.display.TextMediaDisplay import com.google.android.horologist.media.ui.screens.player.PlayerScreen @@ -63,6 +65,7 @@ fun PlayerScreen( PlayerScreen( modifier = modifier, playerUiState = playerUiState, + playerScreenViewModel = playerScreenViewModel, volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, @@ -71,37 +74,56 @@ fun PlayerScreen( @Composable private fun PlayerScreen( - playerUiState: PlayerUiState?, + playerUiState: PlayerUiState, + playerScreenViewModel: PlayerViewModel, volumeUiState: VolumeUiState, onVolumeClick: () -> Unit, onUpdateVolume: (Int) -> Unit, modifier: Modifier = Modifier, ) { + val episode = playerUiState.episodePlayerState.currentEpisode PlayerScreen( mediaDisplay = { - if (playerUiState != null) { - playerUiState.episodePlayerState.currentEpisode?.let { - TextMediaDisplay( - title = it.podcastName, - subtitle = it.title - ) - } + if (episode != null && episode.title.isNotEmpty()) { + TextMediaDisplay( + title = episode.podcastName, + subtitle = episode.title + ) } else { - LoadingMediaDisplay() + TextMediaDisplay( + title = stringResource(R.string.nothing_playing), + subtitle = "" + ) } }, controlButtons = { - PodcastControlButtons( - onPlayButtonClick = { /*TODO*/ }, - onPauseButtonClick = { /*TODO*/ }, - playPauseButtonEnabled = true, - playing = true, - onSeekBackButtonClick = { /*TODO*/ }, - seekBackButtonEnabled = true, - onSeekForwardButtonClick = { /*TODO*/ }, - seekForwardButtonEnabled = true - ) + if (episode != null && episode.title.isNotEmpty()) { + PodcastControlButtons( + onPlayButtonClick = playerScreenViewModel::onPlay, + onPauseButtonClick = playerScreenViewModel::onPause, + playPauseButtonEnabled = true, + playing = playerUiState.episodePlayerState.isPlaying, + onSeekBackButtonClick = playerScreenViewModel::onRewindBy, + seekBackButtonEnabled = true, + onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, + seekForwardButtonEnabled = true, + seekBackButtonIncrement = SeekButtonIncrement.Ten, + seekForwardButtonIncrement = SeekButtonIncrement.Ten, + trackPositionUiModel = playerUiState.trackPositionUiModel + ) + } else { + PodcastControlButtons( + onPlayButtonClick = playerScreenViewModel::onPlay, + onPauseButtonClick = playerScreenViewModel::onPause, + playPauseButtonEnabled = false, + playing = false, + onSeekBackButtonClick = playerScreenViewModel::onRewindBy, + seekBackButtonEnabled = false, + onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, + seekForwardButtonEnabled = false + ) + } }, buttons = { SettingsButtons( @@ -117,7 +139,7 @@ private fun PlayerScreen( isLowRes = RotaryDefaults.isLowResInput(), ), background = { - if (playerUiState != null) { + if (episode != null && episode.podcastImageUrl.isNotEmpty()) { val artworkUri = playerUiState.episodePlayerState.currentEpisode?.podcastImageUrl ArtworkColorBackground( artworkUri = artworkUri, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index d21ea47ce4..70c4efcb9c 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -18,16 +18,21 @@ package com.example.jetcaster.ui.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState +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 kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch +import kotlin.time.toKotlinDuration +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +@com.google.android.horologist.annotations.ExperimentalHorologistApi data class PlayerUiState( - val episodePlayerState: EpisodePlayerState = EpisodePlayerState() + val episodePlayerState: EpisodePlayerState = EpisodePlayerState(), + var trackPositionUiModel: TrackPositionUiModel = TrackPositionUiModel.Actual.ZERO ) /** @@ -38,20 +43,39 @@ class PlayerViewModel @Inject constructor( private val episodePlayer: EpisodePlayer, ) : ViewModel() { - val uiState = MutableStateFlow(null) - - init { - viewModelScope.launch { - val currentEpisode = episodePlayer.currentEpisode - if (currentEpisode != null) { - uiState.value = PlayerUiState( - episodePlayer.playerState.value - ) - } else { - uiState.value = PlayerUiState( - EpisodePlayerState(currentEpisode = PlayerEpisode(title = "Nothing playing")) - ) - } + val uiState = episodePlayer.playerState.map { + PlayerUiState(it, buildPositionModel(it)) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PlayerUiState()) + + 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)) } } diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index d72386237d..b66e8ab5b5 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -59,4 +59,5 @@ Follow Following Not following + Nothing playing From 1dd9c82f27ed6e5daee30d908a0f5245f4427983 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 9 Apr 2024 14:38:24 -0700 Subject: [PATCH 112/143] Display radial gradient on home background. --- .../com/example/jetcaster/ui/home/Home.kt | 190 ++++++++++-------- .../jetcaster/ui/home/discover/Discover.kt | 2 + .../example/jetcaster/util/GradientScrim.kt | 25 +++ 3 files changed, 135 insertions(+), 82 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index d84ef9bf58..8eae495180 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -24,6 +24,7 @@ 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 @@ -65,7 +66,6 @@ import androidx.compose.material3.TabPosition import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.Posture import androidx.compose.material3.adaptive.WindowAdaptiveInfo @@ -124,12 +124,13 @@ 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 java.time.Duration -import java.time.LocalDateTime -import java.time.OffsetDateTime +import com.example.jetcaster.util.radialGradientScrim import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime data class HomeState( val windowSizeClass: WindowSizeClass, @@ -325,41 +326,55 @@ private fun HomeAppBar( isExpanded: Boolean, modifier: Modifier = Modifier, ) { - TopAppBar( - title = { - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() - .padding(end = 16.dp) - ) { - SearchBar( - query = "", - onQueryChange = {}, - placeholder = { - Text(stringResource(id = R.string.search_for_a_podcast)) - }, - onSearch = {}, - active = false, - onActiveChange = {}, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null - ) - }, - trailingIcon = { - Icon( - imageVector = Icons.Default.AccountCircle, - contentDescription = stringResource(R.string.cd_account) - ) - }, - modifier = if (isExpanded) Modifier else Modifier.fillMaxWidth() - ) { } - } - }, - modifier = modifier.padding(vertical = 8.dp) - ) + Row( + horizontalArrangement = Arrangement.End, + modifier = modifier + .fillMaxWidth() + .background(Color.Transparent) + .padding(end = 16.dp, top = 8.dp, bottom = 8.dp) + ) { + SearchBar( + query = "", + onQueryChange = {}, + placeholder = { + Text(stringResource(id = R.string.search_for_a_podcast)) + }, + onSearch = {}, + active = false, + onActiveChange = {}, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = stringResource(R.string.cd_account) + ) + }, + modifier = if (isExpanded) Modifier else Modifier.fillMaxWidth() + ) { } + } +} + +@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 @@ -377,47 +392,52 @@ private fun HomeScreen( val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - Scaffold( - modifier = modifier.windowInsetsPadding( - WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) - ), - topBar = { - HomeAppBar( - isExpanded = homeState.windowSizeClass.isCompact, - modifier = Modifier.fillMaxWidth(), + HomeScreenBackground( + modifier = modifier + .windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) ) - }, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - } - ) { contentPadding -> - // Main Content - val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) - HomeContent( - showGrid = showGrid, - showHomeCategoryTabs = homeState.showHomeCategoryTabs, - featuredPodcasts = homeState.featuredPodcasts, - isRefreshing = homeState.isRefreshing, - selectedHomeCategory = homeState.selectedHomeCategory, - homeCategories = homeState.homeCategories, - filterableCategoriesModel = homeState.filterableCategoriesModel, - podcastCategoryFilterResult = homeState.podcastCategoryFilterResult, - library = homeState.library, - modifier = Modifier.padding(contentPadding), - onPodcastUnfollowed = homeState.onPodcastUnfollowed, - onHomeCategorySelected = homeState.onHomeCategorySelected, - onCategorySelected = homeState.onCategorySelected, - navigateToPodcastDetails = homeState.navigateToPodcastDetails, - navigateToPlayer = homeState.navigateToPlayer, - onTogglePodcastFollowed = homeState.onTogglePodcastFollowed, - onLibraryPodcastSelected = homeState.onLibraryPodcastSelected, - onQueueEpisode = { - coroutineScope.launch { - snackbarHostState.showSnackbar(snackBarText) + ) { + Scaffold( + topBar = { + HomeAppBar( + isExpanded = homeState.windowSizeClass.isCompact, + modifier = Modifier.fillMaxWidth(), + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + containerColor = Color.Transparent + ) { contentPadding -> + // Main Content + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + HomeContent( + showGrid = showGrid, + showHomeCategoryTabs = homeState.showHomeCategoryTabs, + featuredPodcasts = homeState.featuredPodcasts, + isRefreshing = homeState.isRefreshing, + selectedHomeCategory = homeState.selectedHomeCategory, + homeCategories = homeState.homeCategories, + filterableCategoriesModel = homeState.filterableCategoriesModel, + podcastCategoryFilterResult = homeState.podcastCategoryFilterResult, + library = homeState.library, + modifier = Modifier.padding(contentPadding), + onPodcastUnfollowed = homeState.onPodcastUnfollowed, + onHomeCategorySelected = homeState.onHomeCategorySelected, + onCategorySelected = homeState.onCategorySelected, + navigateToPodcastDetails = homeState.navigateToPodcastDetails, + navigateToPlayer = homeState.navigateToPlayer, + onTogglePodcastFollowed = homeState.onTogglePodcastFollowed, + onLibraryPodcastSelected = homeState.onLibraryPodcastSelected, + onQueueEpisode = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + homeState.onQueueEpisode(it) } - homeState.onQueueEpisode(it) - } - ) + ) + } } } @@ -519,7 +539,9 @@ private fun HomeContentColumn( onTogglePodcastFollowed: (PodcastInfo) -> Unit, onQueueEpisode: (PlayerEpisode) -> Unit, ) { - LazyColumn(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { if (featuredPodcasts.isNotEmpty()) { item { FollowedPodcastItem( @@ -538,7 +560,7 @@ private fun HomeContentColumn( } if (showHomeCategoryTabs) { - stickyHeader { + item { HomeCategoryTabs( categories = homeCategories, selectedCategory = selectedHomeCategory, @@ -695,6 +717,7 @@ private fun HomeCategoryTabs( TabRow( selectedTabIndex = selectedIndex, + containerColor = Color.Transparent, indicator = indicator, modifier = modifier, divider = { @@ -749,7 +772,9 @@ private fun FollowedPodcasts( // 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) { + BoxWithConstraints( + modifier = modifier.background(Color.Transparent) + ) { val horizontalPadding = (this.maxWidth - FEATURED_PODCAST_IMAGE_SIZE_DP) / 2 HorizontalPager( state = pagerState, @@ -839,12 +864,13 @@ private fun lastUpdated(updated: OffsetDateTime): String { } } +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable private fun HomeAppBarPreview() { JetcasterTheme { HomeAppBar( - isExpanded = false + isExpanded = false, ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 835fb03441..6f32171088 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.jetcaster.R @@ -131,6 +132,7 @@ private fun PodcastCategoryTabs( ) ScrollableTabRow( selectedTabIndex = selectedIndex, + containerColor = Color.Transparent, divider = {}, /* Disable the built-in divider */ edgePadding = Keyline1, indicator = emptyTabIndicator, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt index fb2b8250df..6713734728 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt @@ -17,11 +17,17 @@ 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 @@ -31,6 +37,25 @@ 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. * From d2f539a3c7dd9ab6d049b4ec2c21196abb38a9e2 Mon Sep 17 00:00:00 2001 From: arriolac Date: Tue, 9 Apr 2024 22:06:10 +0000 Subject: [PATCH 113/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 8eae495180..f51a8c87a3 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -125,12 +125,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 data class HomeState( val windowSizeClass: WindowSizeClass, From 89ef970e28e8b279b6f0e9b4fc233d2aaf13c17c Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Wed, 10 Apr 2024 17:24:29 +0900 Subject: [PATCH 114/143] Update action of the episode selection to navigate the player screen. --- .../example/jetcaster/tv/model/EpisodeList.kt | 7 +- .../example/jetcaster/tv/ui/JetcasterApp.kt | 11 +- .../jetcaster/tv/ui/JetcasterAppState.kt | 4 - .../jetcaster/tv/ui/component/Button.kt | 31 +-- .../jetcaster/tv/ui/component/Catalog.kt | 34 +-- .../jetcaster/tv/ui/component/EpisodeRow.kt | 60 ++++++ .../tv/ui/discover/DiscoverScreen.kt | 11 +- .../tv/ui/discover/DiscoverScreenViewModel.kt | 12 +- .../jetcaster/tv/ui/episode/EpisodeScreen.kt | 2 + .../jetcaster/tv/ui/library/LibraryScreen.kt | 13 +- .../tv/ui/library/LibraryScreenViewModel.kt | 12 +- .../jetcaster/tv/ui/player/PlayerScreen.kt | 44 +--- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 195 +++++++++++++----- .../tv/ui/podcast/PodcastScreenViewModel.kt | 16 +- 14 files changed, 277 insertions(+), 175 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt index 96bf5b25bd..4743f10a99 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -17,12 +17,7 @@ package com.example.jetcaster.tv.model import androidx.compose.runtime.Immutable -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.model.PlayerEpisode @Immutable -data class EpisodeList(val member: List) : List by member - -// ToDo: merge into EpisodeList as PlayerEpisode is the exposed data structure of EpisodeToPodcast -@Immutable -data class PlayerEpisodeList(val member: List) : List by member +data class EpisodeList(val member: List) : List by member diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index 4cce2eb618..529da265b4 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -122,8 +122,8 @@ private fun Route(jetcasterAppState: JetcasterAppState) { showPodcastDetails = { jetcasterAppState.showPodcastDetails(it.uri) }, - showEpisodeDetails = { - jetcasterAppState.showEpisodeDetails(it.episode.uri) + playEpisode = { + jetcasterAppState.playEpisode() }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) @@ -139,8 +139,8 @@ private fun Route(jetcasterAppState: JetcasterAppState) { showPodcastDetails = { jetcasterAppState.showPodcastDetails(it.podcast.uri) }, - showEpisodeDetails = { - jetcasterAppState.showEpisodeDetails(it.episode.uri) + playEpisode = { + jetcasterAppState.playEpisode() }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) @@ -164,8 +164,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { PodcastScreen( backToHomeScreen = jetcasterAppState::navigateToDiscover, playEpisode = { + jetcasterAppState.playEpisode() }, - showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.episode.uri) }, + showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.uri) }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) .fillMaxSize(), diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt index 120e801a84..413a1e615b 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -70,10 +70,6 @@ class JetcasterAppState( navHostController.popBackStack() navigateToDiscover() } - - fun navigateBack() { - navHostController.popBackStack() - } } @Composable diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt index 5a29ba9fd1..68c6cf685d 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt @@ -16,8 +16,6 @@ package com.example.jetcaster.tv.ui.component -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Forward10 @@ -30,27 +28,27 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ButtonScale import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.IconButton -import androidx.tv.material3.Text import com.example.jetcaster.tv.R @OptIn(ExperimentalTvMaterial3Api::class) @Composable internal fun PlayButton( onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + scale: ButtonScale = ButtonDefaults.scale(), ) = ButtonWithIcon( icon = Icons.Outlined.PlayArrow, label = stringResource(R.string.label_play), onClick = onClick, - modifier = modifier + modifier = modifier, + scale = scale ) @OptIn(ExperimentalTvMaterial3Api::class) @@ -81,25 +79,6 @@ internal fun InfoButton( } } -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -internal fun ButtonWithIcon( - icon: ImageVector, - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) = - Button(onClick = onClick, modifier = modifier) { - Icon( - icon, - contentDescription = null, - modifier = Modifier - .width(ButtonDefaults.IconSize) - .padding(end = ButtonDefaults.IconSpacing) - ) - Text(text = label) - } - @OptIn(ExperimentalTvMaterial3Api::class) @Composable internal fun PreviousButton( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index 3db298ce72..b6971fed71 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -40,9 +40,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.StandardCardLayout import androidx.tv.material3.Text import coil.compose.AsyncImage -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.model.PlayerEpisode import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList @@ -53,7 +53,7 @@ internal fun Catalog( podcastList: PodcastList, latestEpisodeList: EpisodeList, onPodcastSelected: (PodcastWithExtraInfo) -> Unit, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, state: TvLazyListState = rememberTvLazyListState(), header: (@Composable () -> Unit)? = null, @@ -109,7 +109,7 @@ private fun PodcastSection( @Composable private fun LatestEpisodeSection( episodeList: EpisodeList, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, title: String? = null ) { @@ -118,8 +118,8 @@ private fun LatestEpisodeSection( title = title ) { EpisodeRow( - episodeList = episodeList, - onEpisodeSelected = onEpisodeSelected, + playerEpisodeList = episodeList, + onSelected = onEpisodeSelected, modifier = Modifier.focusRestorer() ) } @@ -192,27 +192,3 @@ internal fun PodcastCard( modifier = modifier, ) } - -@Composable -private fun EpisodeRow( - episodeList: EpisodeList, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(), - horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gap.episodeRow), -) { - TvLazyRow( - contentPadding = contentPadding, - horizontalArrangement = horizontalArrangement, - modifier = modifier, - ) { - items(episodeList) { - EpisodeCard( - episode = it, - onClick = { onEpisodeSelected(it) }, - cardWidth = JetcasterAppDefaults.cardWidth.small - ) - } - } -} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt new file mode 100644 index 0000000000..29623a931d --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt @@ -0,0 +1,60 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.itemsIndexed +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun EpisodeRow( + playerEpisodeList: EpisodeList, + onSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + contentPadding: PaddingValues = PaddingValues(), + focusRequester: FocusRequester = remember { FocusRequester() } +) { + TvLazyRow( + modifier = modifier, + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + ) { + itemsIndexed(playerEpisodeList) { index, item -> + val cardModifier = if (index == 0) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + } + EpisodeCard( + playerEpisode = item, + onClick = { onSelected(item) }, + modifier = cardModifier + ) + } + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index efcadd57b0..037ddaa25c 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -37,9 +37,9 @@ import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text 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.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList @@ -50,7 +50,7 @@ import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable fun DiscoverScreen( showPodcastDetails: (Podcast) -> Unit, - showEpisodeDetails: (EpisodeToPodcast) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, discoverScreenViewModel: DiscoverScreenViewModel = hiltViewModel() ) { @@ -73,7 +73,10 @@ fun DiscoverScreen( latestEpisodeList = s.latestEpisodeList, onPodcastSelected = { showPodcastDetails(it.podcast) }, onCategorySelected = discoverScreenViewModel::selectCategory, - onEpisodeSelected = showEpisodeDetails, + onEpisodeSelected = { + discoverScreenViewModel.play(it) + playEpisode(it) + }, modifier = Modifier .fillMaxSize() .then(modifier) @@ -90,7 +93,7 @@ private fun CatalogWithCategorySelection( selectedCategory: Category, latestEpisodeList: EpisodeList, onPodcastSelected: (PodcastWithExtraInfo) -> Unit, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, onCategorySelected: (Category) -> Unit, modifier: Modifier = Modifier, state: TvLazyListState = rememberTvLazyListState(), diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 19fca0bb9a..8609d73397 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -19,8 +19,11 @@ package com.example.jetcaster.tv.ui.discover import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.CategoryStore import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList @@ -40,6 +43,7 @@ import kotlinx.coroutines.launch class DiscoverScreenViewModel @Inject constructor( private val podcastsRepository: PodcastsRepository, private val categoryStore: CategoryStore, + private val episodePlayer: EpisodePlayer, ) : ViewModel() { private val _selectedCategory = MutableStateFlow(null) @@ -80,8 +84,8 @@ class DiscoverScreenViewModel @Inject constructor( } else { flowOf(emptyList()) } - }.map { - EpisodeList(it) + }.map { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) } val uiState = combine( @@ -114,6 +118,10 @@ class DiscoverScreenViewModel @Inject constructor( _selectedCategory.value = category } + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + private fun refresh() { viewModelScope.launch { podcastsRepository.updatePodcasts(false) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt index 533e23d8ea..7765e3f5fc 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -146,6 +146,7 @@ private fun EpisodeInfo( } } +@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun Controls( playEpisode: () -> Unit, @@ -154,6 +155,7 @@ private fun Controls( ) { Row( horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + verticalAlignment = Alignment.CenterVertically, modifier = modifier ) { PlayButton(onClick = playEpisode) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index 0b52136073..9073084fe4 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -36,8 +36,8 @@ import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList @@ -50,7 +50,7 @@ fun LibraryScreen( modifier: Modifier = Modifier, navigateToDiscover: () -> Unit, showPodcastDetails: (PodcastWithExtraInfo) -> Unit, - showEpisodeDetails: (EpisodeToPodcast) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, libraryScreenViewModel: LibraryScreenViewModel = hiltViewModel() ) { val uiState by libraryScreenViewModel.uiState.collectAsState() @@ -64,7 +64,10 @@ fun LibraryScreen( podcastList = s.subscribedPodcastList, episodeList = s.latestEpisodeList, showPodcastDetails = showPodcastDetails, - showEpisodeDetails = showEpisodeDetails, + onEpisodeSelected = { + libraryScreenViewModel.playEpisode(it) + playEpisode(it) + }, modifier = modifier, ) } @@ -76,7 +79,7 @@ private fun Library( podcastList: PodcastList, episodeList: EpisodeList, showPodcastDetails: (PodcastWithExtraInfo) -> Unit, - showEpisodeDetails: (EpisodeToPodcast) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() }, ) { @@ -88,7 +91,7 @@ private fun Library( podcastList = podcastList, latestEpisodeList = episodeList, onPodcastSelected = showPodcastDetails, - onEpisodeSelected = showEpisodeDetails, + onEpisodeSelected = onEpisodeSelected, modifier = modifier .focusRequester(focusRequester) .focusRestorer() diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index 92a968a201..426fe861ea 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -18,9 +18,12 @@ package com.example.jetcaster.tv.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.toPlayerEpisode 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.player.EpisodePlayer import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList import dagger.hilt.android.lifecycle.HiltViewModel @@ -39,6 +42,7 @@ 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 { @@ -58,8 +62,8 @@ class LibraryScreenViewModel @Inject constructor( } else { flowOf(emptyList()) } - }.map { - EpisodeList(it) + }.map { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) } val uiState = @@ -80,6 +84,10 @@ class LibraryScreenViewModel @Inject constructor( podcastsRepository.updatePodcasts(false) } } + + fun playEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } } sealed interface LibraryScreenUiState { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index e215d47952..3a222abce0 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -57,8 +57,6 @@ 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.tv.foundation.lazy.list.TvLazyRow -import androidx.tv.foundation.lazy.list.itemsIndexed import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme @@ -66,11 +64,11 @@ import androidx.tv.material3.Text import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.tv.R -import com.example.jetcaster.tv.model.PlayerEpisodeList +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.EpisodeCard 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 @@ -142,7 +140,7 @@ private fun Player( if (currentEpisode != null) { EpisodePlayerWithBackground( playerEpisode = currentEpisode, - queue = PlayerEpisodeList(episodePlayerState.queue), + queue = EpisodeList(episodePlayerState.queue), isPlaying = episodePlayerState.isPlaying, timeElapsed = episodePlayerState.timeElapsed, play = play, @@ -163,7 +161,7 @@ private fun Player( @Composable private fun EpisodePlayerWithBackground( playerEpisode: PlayerEpisode, - queue: PlayerEpisodeList, + queue: EpisodeList, isPlaying: Boolean, timeElapsed: Duration, play: () -> Unit, @@ -416,40 +414,10 @@ private fun NoEpisodeInQueue( } } -@Composable -private fun PlayerQueue( - playerEpisodeList: PlayerEpisodeList, - onSelected: (PlayerEpisode) -> Unit, - modifier: Modifier = Modifier, - horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gap.item), - contentPadding: PaddingValues = PaddingValues(), - focusRequester: FocusRequester = remember { FocusRequester() } -) { - TvLazyRow( - modifier = modifier, - contentPadding = contentPadding, - horizontalArrangement = horizontalArrangement, - ) { - itemsIndexed(playerEpisodeList) { index, item -> - val cardModifier = if (index == 0) { - Modifier.focusRequester(focusRequester) - } else { - Modifier - } - EpisodeCard( - playerEpisode = item, - onClick = { onSelected(item) }, - modifier = cardModifier - ) - } - } -} - @OptIn(ExperimentalComposeUiApi::class) @Composable private fun PlayerQueueOverlay( - playerEpisodeList: PlayerEpisodeList, + playerEpisodeList: EpisodeList, onSelected: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, horizontalArrangement: Arrangement.Horizontal = @@ -481,7 +449,7 @@ private fun PlayerQueueOverlay( }, contentAlignment = contentAlignment, ) { - PlayerQueue( + EpisodeRow( playerEpisodeList = playerEpisodeList, onSelected = onSelected, horizontalArrangement = horizontalArrangement, diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index c704d8f591..eeef870c77 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -16,6 +16,8 @@ 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 @@ -24,6 +26,7 @@ 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Remove @@ -31,41 +34,51 @@ 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.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.items import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -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.model.PlayerEpisode import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.ui.component.Background 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 PodcastScreen( backToHomeScreen: () -> Unit, - playEpisode: (Episode) -> Unit, - showEpisodeDetails: (EpisodeToPodcast) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, podcastScreenViewModel: PodcastScreenViewModel = hiltViewModel(), ) { @@ -79,7 +92,11 @@ fun PodcastScreen( isSubscribed = s.isSubscribed, subscribe = podcastScreenViewModel::subscribe, unsubscribe = podcastScreenViewModel::unsubscribe, - playEpisode = playEpisode, + playEpisode = { + podcastScreenViewModel.play(it) + playEpisode(it) + }, + enqueue = podcastScreenViewModel::enqueue, showEpisodeDetails = showEpisodeDetails, ) } @@ -92,8 +109,9 @@ private fun PodcastDetailsWithBackground( isSubscribed: Boolean, subscribe: (Podcast, Boolean) -> Unit, unsubscribe: (Podcast, Boolean) -> Unit, - playEpisode: (Episode) -> Unit, - showEpisodeDetails: (EpisodeToPodcast) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() } ) { @@ -108,6 +126,7 @@ private fun PodcastDetailsWithBackground( playEpisode = playEpisode, focusRequester = focusRequester, showEpisodeDetails = showEpisodeDetails, + enqueue = enqueue, modifier = Modifier .fillMaxSize() .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) @@ -123,33 +142,38 @@ private fun PodcastDetails( isSubscribed: Boolean, subscribe: (Podcast, Boolean) -> Unit, unsubscribe: (Podcast, Boolean) -> Unit, - playEpisode: (Episode) -> Unit, - showEpisodeDetails: (EpisodeToPodcast) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() } ) { - Row( + TwoColumn( modifier = modifier, horizontalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn) - ) { - PodcastInfo( - podcast = podcast, - isSubscribed = isSubscribed, - subscribe = subscribe, - unsubscribe = unsubscribe, - modifier = Modifier.weight(1f), - ) - PodcastEpisodeList( - episodeList = episodeList, - onEpisodeSelected = { playEpisode(it.episode) }, - onDetailsRequested = showEpisodeDetails, - modifier = Modifier - .focusRequester(focusRequester) - .focusRestorer() - .weight(1f) - ) - } + Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn), + first = { + PodcastInfo( + podcast = podcast, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + modifier = Modifier.weight(0.3f), + ) + }, + second = { + PodcastEpisodeList( + episodeList = episodeList, + playEpisode = { playEpisode(it) }, + showDetails = showEpisodeDetails, + enqueue = enqueue, + modifier = Modifier + .focusRequester(focusRequester) + .focusRestorer() + .weight(0.7f) + ) + } + ) LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -236,8 +260,9 @@ private fun ToggleSubscriptionButton( @Composable private fun PodcastEpisodeList( episodeList: EpisodeList, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, - onDetailsRequested: (EpisodeToPodcast) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier ) { TvLazyColumn( @@ -246,9 +271,10 @@ private fun PodcastEpisodeList( ) { items(episodeList) { EpisodeListItem( - episodeToPodcast = it, - onEpisodeSelected = onEpisodeSelected, - onInfoClicked = onDetailsRequested + playerEpisode = it, + onEpisodeSelected = { playEpisode(it) }, + onInfoClicked = { showDetails(it) }, + onEnqueueClicked = { enqueue(it) }, ) } } @@ -257,35 +283,100 @@ private fun PodcastEpisodeList( @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun EpisodeListItem( - episodeToPodcast: EpisodeToPodcast, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, - onInfoClicked: (EpisodeToPodcast) -> Unit, + playerEpisode: PlayerEpisode, + onEpisodeSelected: () -> Unit, + onInfoClicked: () -> Unit, + onEnqueueClicked: () -> Unit, modifier: Modifier = Modifier, - selected: Boolean = false + borderWidth: Dp = 2.dp, + cornerRadius: Dp = 12.dp, ) { - val duration = episodeToPodcast.episode.duration + var hasFocus by remember { + mutableStateOf(false) + } + val shape = RoundedCornerShape(cornerRadius) + + val backgroundColor = if (hasFocus) { + MaterialTheme.colorScheme.surface + } else { + Color.Transparent + } - ListItem( - selected = selected, - onClick = { onInfoClicked(episodeToPodcast) }, - onLongClick = { onEpisodeSelected(episodeToPodcast) }, - supportingContent = { - if (duration != null) { - EpisodeDataAndDuration(episodeToPodcast.episode.published, duration) + 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) + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@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 ) { - EpisodeTitle(episode = episodeToPodcast.episode) + + 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) + } + } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun EpisodeTitle(episode: Episode, modifier: Modifier = Modifier) { +private fun EpisodeTitle(playerEpisode: PlayerEpisode, modifier: Modifier = Modifier) { Text( - text = episode.title, - style = MaterialTheme.typography.bodyMedium, + text = playerEpisode.title, + style = MaterialTheme.typography.titleLarge, modifier = modifier ) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt index c791355d9a..fcf087ffbe 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -20,8 +20,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel @@ -40,6 +43,7 @@ class PodcastScreenViewModel @Inject constructor( handle: SavedStateHandle, private val podcastStore: PodcastStore, episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, ) : ViewModel() { private val podcastUri = handle.get(Screen.Podcast.PARAMETER_NAME) @@ -61,8 +65,8 @@ class PodcastScreenViewModel @Inject constructor( } else { flowOf(emptyList()) } - }.map { - EpisodeList(it) + }.map { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) } private val subscribedPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode() @@ -99,6 +103,14 @@ class PodcastScreenViewModel @Inject constructor( } } } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + fun enqueue(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } } sealed interface PodcastScreenUiState { From 250366bff23fde33102e697e376d1cdb1dcf66a8 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Tue, 9 Apr 2024 15:21:34 +0100 Subject: [PATCH 115/143] Adds podcasts screen --- .../java/com/example/jetcaster/WearApp.kt | 9 +- .../jetcaster/ui/home/HomeViewModel.kt | 4 - .../jetcaster/ui/library/PodcastsScreen.kt | 208 ++++++++++++++++++ .../jetcaster/ui/library/PodcastsViewModel.kt | 63 ++++++ .../wear/src/main/res/values/strings.xml | 4 + 5 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt index 6869c112c6..be1b6201f8 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -45,8 +45,10 @@ import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast import com.example.jetcaster.ui.LatestEpisodes +import com.example.jetcaster.ui.YourPodcasts import com.example.jetcaster.ui.home.HomeScreen import com.example.jetcaster.ui.library.LatestEpisodesScreen +import com.example.jetcaster.ui.library.PodcastsScreen import com.example.jetcaster.ui.player.PlayerScreen import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer @@ -88,7 +90,6 @@ fun WearApp() { mediaEntityScreen = {}, playlistsScreen = {}, settingsScreen = {}, - navHostState = navHostState, snackbarViewModel = snackbarViewModel, volumeViewModel = volumeViewModel, @@ -108,6 +109,12 @@ fun WearApp() { } ) } + composable(route = YourPodcasts.navRoute) { + PodcastsScreen( + onPodcastsItemClick = { navController.navigateToPlayer() }, + onErrorDialogCancelClick = { navController.popBackStack() } + ) + } }, ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index b1851dc961..513a83b0fe 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -119,10 +119,6 @@ class HomeViewModel @Inject constructor( } } - fun onHomeCategorySelected(category: HomeCategory) { - selectedHomeCategory.value = category - } - fun onPodcastUnfollowed(podcastUri: String) { viewModelScope.launch { podcastStore.unfollowPodcast(podcastUri) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt new file mode 100644 index 0000000000..a78da459e7 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.dialog.Alert +import androidx.wear.compose.material.dialog.Dialog +import com.example.jetcaster.R +import com.example.jetcaster.core.data.model.PodcastInfo +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.composables.Section +import com.google.android.horologist.composables.SectionedList +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberColumnState +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.Title +import com.google.android.horologist.images.coil.CoilPaintable + +@Composable +fun PodcastsScreen( + podcastsViewModel: PodcastsViewModel = hiltViewModel(), + onPodcastsItemClick: (PodcastInfo) -> Unit, + onErrorDialogCancelClick: () -> Unit, +) { + val uiState by podcastsViewModel.uiState.collectAsStateWithLifecycle() + + val modifiedState = when (uiState) { + is PodcastsScreenState.Loaded -> { + val modifiedPodcast = (uiState as PodcastsScreenState.Loaded).podcastList.map { + it.takeIf { it.title.isNotEmpty() } + ?: it.copy(title = stringResource(id = R.string.no_title)) + } + + PodcastsScreenState.Loaded(modifiedPodcast) + } + + PodcastsScreenState.Empty, + PodcastsScreenState.Loading, + -> uiState + } + + PodcastsScreen( + podcastsScreenState = modifiedState, + onPodcastsItemClick = onPodcastsItemClick + ) + + Dialog( + showDialog = modifiedState == PodcastsScreenState.Empty, + onDismissRequest = onErrorDialogCancelClick, + scrollState = rememberScalingLazyListState(), + ) { + Alert( + title = { + Text( + text = stringResource(R.string.podcasts_no_podcasts), + color = MaterialTheme.colors.onBackground, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.title3, + ) + }, + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + + Button( + imageVector = Icons.Default.Close, + contentDescription = stringResource( + id = R.string + .podcasts_failed_dialog_cancel_button_content_description, + ), + onClick = onErrorDialogCancelClick, + modifier = Modifier + .size(24.dp) + .wrapContentSize(align = Alignment.Center), + colors = ButtonDefaults.secondaryButtonColors() + ) + } + } + } + } +} + +@ExperimentalHorologistApi +@Composable +fun PodcastsScreen( + podcastsScreenState: PodcastsScreenState, + onPodcastsItemClick: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, + podcastItemArtworkPlaceholder: Painter? = null, +) { + + val podcastContent: @Composable (podcast: PodcastInfo) -> Unit = { podcast -> + Chip( + label = podcast.title, + onClick = { onPodcastsItemClick(podcast) }, + icon = CoilPaintable(podcast.imageUrl, podcastItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) + } + + PodcastsScreen( + podcastsScreenState = podcastsScreenState, + modifier = modifier, + content = { podcast -> + Chip( + label = podcast.title, + onClick = { onPodcastsItemClick(podcast) }, + icon = CoilPaintable(podcast.imageUrl, podcastItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) + } + ) +} + +@ExperimentalHorologistApi +@Composable +fun PodcastsScreen( + podcastsScreenState: PodcastsScreenState, + modifier: Modifier = Modifier, + content: @Composable (podcast: T) -> Unit, +) { + val columnState = rememberColumnState() + ScreenScaffold(scrollState = columnState) { + SectionedList( + modifier = modifier, + columnState = columnState, + ) { + val sectionState = when (podcastsScreenState) { + is PodcastsScreenState.Loaded -> { + Section.State.Loaded(podcastsScreenState.podcastList) + } + + PodcastsScreenState.Empty -> Section.State.Failed + PodcastsScreenState.Loading -> Section.State.Loading + } + + section(state = sectionState) { + header { + Title( + R.string.podcasts, + Modifier.padding(bottom = 12.dp), + ) + } + + loaded { content(it) } + + loading(count = 4) { + Column { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + } + } + } +} + +@ExperimentalHorologistApi +public sealed class PodcastsScreenState { + + public object Loading : PodcastsScreenState() + + public data class Loaded( + val podcastList: List, + ) : PodcastsScreenState() + + public object Empty : PodcastsScreenState() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt new file mode 100644 index 0000000000..e7bb50fb85 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.repository.PodcastStore +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class PodcastsViewModel @Inject constructor( + podcastStore: PodcastStore, +) : ViewModel() { + + val uiState: StateFlow> = + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10).map { + if (it.isNotEmpty()) { + PodcastsScreenState.Loaded(it.map(PodcastMapper::map)) + } else { + PodcastsScreenState.Empty + } + }.catch { + emit(PodcastsScreenState.Empty) + }.stateIn( + viewModelScope, + started = SharingStarted.Eagerly, + initialValue = PodcastsScreenState.Loading, + ) +} + +object PodcastMapper { + + /** + * Maps from [Podcast]. + */ + fun map( + podcastWithExtraInfo: PodcastWithExtraInfo, + ): PodcastInfo = + podcastWithExtraInfo.asExternalModel() +} diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index b66e8ab5b5..ec7f044650 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -60,4 +60,8 @@ Following Not following Nothing playing + + No podcasts available at the moment + No title + Cancel From 8fc4824e265409e39aaf41ca83834ce71a15d4cc Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 10 Apr 2024 10:16:13 -0700 Subject: [PATCH 116/143] [Jetcaster] Create model module and add PlayerImage to shared design system. --- Jetcaster/app/build.gradle.kts | 1 + .../com/example/jetcaster/ui/home/Home.kt | 20 +++--- .../jetcaster/ui/home/HomeViewModel.kt | 14 ++-- .../example/jetcaster/ui/home/PreviewData.kt | 10 +-- .../ui/home/category/PodcastCategory.kt | 8 +-- .../jetcaster/ui/home/discover/Discover.kt | 16 +++-- .../jetcaster/ui/home/library/Library.kt | 6 +- .../jetcaster/ui/player/PlayerScreen.kt | 52 +++++++++++++- .../jetcaster/ui/player/PlayerViewModel.kt | 6 +- .../ui/podcast/PodcastDetailsScreen.kt | 6 +- .../ui/podcast/PodcastDetailsViewModel.kt | 8 +-- .../jetcaster/ui/shared/EpisodeListItem.kt | 6 +- Jetcaster/core/build.gradle.kts | 1 + Jetcaster/core/model/.gitignore | 1 + Jetcaster/core/model/build.gradle.kts | 35 +++++++++ Jetcaster/core/model/consumer-rules.pro | 0 Jetcaster/core/model/proguard-rules.pro | 21 ++++++ .../core/model/src/main/AndroidManifest.xml | 4 ++ .../jetcaster/core}/model/CategoryInfo.kt | 10 +-- .../jetcaster/core}/model/EpisodeInfo.kt | 14 +--- .../core}/model/FilterableCategoriesModel.kt | 2 +- .../jetcaster/core}/model/LibraryInfo.kt | 2 +- .../jetcaster/core}/model/PlayerEpisode.kt | 16 +---- .../model/PodcastCategoryFilterResult.kt | 10 +-- .../jetcaster/core}/model/PodcastInfo.kt | 19 +---- .../core/data/database/model/Category.kt | 7 ++ .../core/data/database/model/Episode.kt | 12 ++++ .../data/database/model/EpisodeToPodcast.kt | 21 ++++++ .../core/data/database/model/Podcast.kt | 10 +++ .../database/model/PodcastWithExtraInfo.kt | 7 ++ .../domain/FilterableCategoriesUseCase.kt | 6 +- .../domain/PodcastCategoryFilterUseCase.kt | 10 +-- .../jetcaster/core/player/EpisodePlayer.kt | 4 +- .../core/player/MockEpisodePlayer.kt | 2 +- .../domain/FilterableCategoriesUseCaseTest.kt | 2 +- .../PodcastCategoryFilterUseCaseTest.kt | 4 +- Jetcaster/designsystem/build.gradle.kts | 11 +++ .../designsystem/component/ImageBackground.kt | 72 +++++++++++++++++++ Jetcaster/settings.gradle.kts | 2 +- Jetcaster/tv-app/build.gradle.kts | 3 +- .../jetcaster/tv/ui/component/Background.kt | 65 +++-------------- .../example/jetcaster/ui/home/HomeScreen.kt | 6 +- .../jetcaster/ui/home/HomeViewModel.kt | 14 ++-- .../ui/library/LatestEpisodeViewModel.kt | 4 +- .../ui/library/LatestEpisodesScreen.kt | 8 +-- 45 files changed, 353 insertions(+), 205 deletions(-) create mode 100644 Jetcaster/core/model/.gitignore create mode 100644 Jetcaster/core/model/build.gradle.kts create mode 100644 Jetcaster/core/model/consumer-rules.pro create mode 100644 Jetcaster/core/model/proguard-rules.pro create mode 100644 Jetcaster/core/model/src/main/AndroidManifest.xml rename Jetcaster/core/{src/main/java/com/example/jetcaster/core/data => model/src/main/java/com/example/jetcaster/core}/model/CategoryInfo.kt (76%) rename Jetcaster/core/{src/main/java/com/example/jetcaster/core/data => model/src/main/java/com/example/jetcaster/core}/model/EpisodeInfo.kt (72%) rename Jetcaster/core/{src/main/java/com/example/jetcaster/core/data => model/src/main/java/com/example/jetcaster/core}/model/FilterableCategoriesModel.kt (95%) rename Jetcaster/core/{src/main/java/com/example/jetcaster/core/data => model/src/main/java/com/example/jetcaster/core}/model/LibraryInfo.kt (94%) rename Jetcaster/core/{src/main/java/com/example/jetcaster/core/data => model/src/main/java/com/example/jetcaster/core}/model/PlayerEpisode.kt (73%) rename Jetcaster/core/{src/main/java/com/example/jetcaster/core/data => model/src/main/java/com/example/jetcaster/core}/model/PodcastCategoryFilterResult.kt (75%) rename Jetcaster/core/{src/main/java/com/example/jetcaster/core/data => model/src/main/java/com/example/jetcaster/core}/model/PodcastInfo.kt (61%) create mode 100644 Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index 311ea2a67e..eae707a950 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -85,6 +85,7 @@ android { } dependencies { + implementation(project(":core:model")) val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index d84ef9bf58..ffd23417a9 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -108,13 +108,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowHeightSizeClass import coil.compose.AsyncImage import com.example.jetcaster.R -import com.example.jetcaster.core.data.model.CategoryInfo -import com.example.jetcaster.core.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.FilterableCategoriesModel -import com.example.jetcaster.core.data.model.LibraryInfo -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.core.data.model.PodcastInfo +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.PlayerEpisode +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.home.library.libraryItems import com.example.jetcaster.ui.podcast.PodcastDetailsScreen @@ -124,12 +124,12 @@ 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 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime data class HomeState( val windowSizeClass: WindowSizeClass, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index bfeddd3e28..da80232a9b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -19,18 +19,18 @@ package com.example.jetcaster.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.asExternalModel import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase -import com.example.jetcaster.core.data.model.CategoryInfo -import com.example.jetcaster.core.data.model.FilterableCategoriesModel -import com.example.jetcaster.core.data.model.LibraryInfo -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.core.data.model.asExternalModel 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.CategoryInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index 5030d56866..2341e7ac82 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -16,10 +16,10 @@ package com.example.jetcaster.ui.home -import com.example.jetcaster.core.data.model.CategoryInfo -import com.example.jetcaster.core.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.PodcastCategoryEpisode -import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastCategoryEpisode +import com.example.jetcaster.core.model.PodcastInfo import java.time.OffsetDateTime import java.time.ZoneOffset @@ -50,7 +50,7 @@ val PreviewEpisodes = listOf( 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!", + "Tsurkan from the System UI team about... Bubbles!", published = OffsetDateTime.of( 2020, 6, 2, 9, 27, 0, 0, ZoneOffset.of("-0800") diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index 857a5572d1..6fc7607bba 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -46,11 +46,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import com.example.jetcaster.core.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.core.data.model.PodcastInfo import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.shared.EpisodeListItem diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 835fb03441..2ff5b0e1e0 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -38,13 +38,13 @@ 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.data.model.CategoryInfo -import com.example.jetcaster.core.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.FilterableCategoriesModel -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.core.data.model.PodcastInfo import com.example.jetcaster.designsystem.theme.Keyline1 +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.PlayerEpisode +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.ui.home.category.podcastCategory import com.example.jetcaster.util.fullWidthItem @@ -183,7 +183,9 @@ private fun ChoiceChipContent( Icon( imageVector = Icons.Default.Check, contentDescription = stringResource(id = R.string.cd_selected_category), - modifier = Modifier.height(18.dp).padding(end = 8.dp) + modifier = Modifier + .height(18.dp) + .padding(end = 8.dp) ) } Text( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index d33934376b..e27ad745a4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -28,10 +28,10 @@ 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.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.LibraryInfo -import com.example.jetcaster.core.data.model.PlayerEpisode import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.ui.shared.EpisodeListItem import com.example.jetcaster.util.fullWidthItem diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 93c7b8e0c7..67c74d98a7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -67,6 +67,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -87,8 +88,9 @@ import androidx.window.layout.FoldingFeature import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.designsystem.component.ImageBackgroundColorScrim import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.isBookPosture import com.example.jetcaster.util.isSeparatingPosture @@ -150,7 +152,7 @@ private fun PlayerScreen( } Surface(modifier) { if (uiState.episodePlayerState.currentEpisode != null) { - PlayerContent( + PlayerContentWithBackground( uiState, windowSizeClass, displayFeatures, @@ -168,6 +170,52 @@ private fun PlayerScreen( } } +@Composable +private fun PlayerBackground( + episode: PlayerEpisode?, + modifier: Modifier, +) { + ImageBackgroundColorScrim( + url = episode?.podcastImageUrl, + color = Color.Black.copy(alpha = 0.68f), + modifier = modifier, + ) +} + +@Composable +fun PlayerContentWithBackground( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + PlayerBackground( + episode = uiState.episodePlayerState.currentEpisode, + modifier = Modifier.fillMaxSize() + ) + PlayerContent( + uiState, + windowSizeClass, + displayFeatures, + onBackPress, + onPlayPress, + onPausePress, + onAdvanceBy, + onRewindBy, + onNext, + onPrevious, + ) + } +} + @Composable fun PlayerContent( uiState: PlayerUiState, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 7748a10c20..2dfdfd401f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -23,18 +23,18 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.core.data.database.model.toPlayerEpisode 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.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 +import java.time.Duration +import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index 0237a4eb1d..f8db646b71 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -65,9 +65,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R -import com.example.jetcaster.core.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 22c78fa9c9..858289bc0d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -19,12 +19,12 @@ package com.example.jetcaster.ui.podcast import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.database.model.asExternalModel 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.PlayerEpisode +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.player.EpisodePlayer import dagger.assisted.Assisted import dagger.assisted.AssistedFactory diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index 693be0136a..bafb863074 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -55,9 +55,9 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R -import com.example.jetcaster.core.data.model.EpisodeInfo -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.theme.JetcasterTheme diff --git a/Jetcaster/core/build.gradle.kts b/Jetcaster/core/build.gradle.kts index 2457fad326..402d1e1d41 100644 --- a/Jetcaster/core/build.gradle.kts +++ b/Jetcaster/core/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.compose.runtime) + implementation(project(":core:model")) // Image loading implementation(libs.coil.kt.compose) diff --git a/Jetcaster/core/model/.gitignore b/Jetcaster/core/model/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/model/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/model/build.gradle.kts b/Jetcaster/core/model/build.gradle.kts new file mode 100644 index 0000000000..2e4dd2b851 --- /dev/null +++ b/Jetcaster/core/model/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.jetcaster.core.model" + + 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 + } +} + +dependencies { + coreLibraryDesugaring(libs.core.jdk.desugaring) +} diff --git a/Jetcaster/core/model/consumer-rules.pro b/Jetcaster/core/model/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/model/proguard-rules.pro b/Jetcaster/core/model/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/model/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/model/src/main/AndroidManifest.xml b/Jetcaster/core/model/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/model/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt similarity index 76% rename from Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt index 766ae3cc0f..9ebf1a9577 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/CategoryInfo.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt @@ -14,17 +14,9 @@ * limitations under the License. */ -package com.example.jetcaster.core.data.model - -import com.example.jetcaster.core.data.database.model.Category +package com.example.jetcaster.core.model data class CategoryInfo( val id: Long, val name: String ) - -fun Category.asExternalModel() = - CategoryInfo( - id = id, - name = name - ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt similarity index 72% rename from Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt index 4f184f7a6c..88b2d1f158 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/EpisodeInfo.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt @@ -14,9 +14,8 @@ * limitations under the License. */ -package com.example.jetcaster.core.data.model +package com.example.jetcaster.core.model -import com.example.jetcaster.core.data.database.model.Episode import java.time.Duration import java.time.OffsetDateTime @@ -32,14 +31,3 @@ data class EpisodeInfo( val published: OffsetDateTime = OffsetDateTime.MIN, val duration: Duration? = null, ) - -fun Episode.asExternalModel(): EpisodeInfo = - EpisodeInfo( - uri = uri, - title = title, - subTitle = subtitle ?: "", - summary = summary ?: "", - author = author ?: "", - published = published, - duration = duration, - ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/FilterableCategoriesModel.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt similarity index 95% rename from Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/FilterableCategoriesModel.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt index ca02e7fb56..4cca646940 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/FilterableCategoriesModel.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.core.data.model +package com.example.jetcaster.core.model /** * Model holding a list of categories and a selected category in the collection diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt similarity index 94% rename from Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt index 7a1a3df058..a502a0bb29 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/LibraryInfo.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.core.data.model +package com.example.jetcaster.core.model data class LibraryInfo( val podcast: PodcastInfo? = null, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PlayerEpisode.kt similarity index 73% rename from Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PlayerEpisode.kt index e7305e5b5e..7b4c7d4ad2 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PlayerEpisode.kt @@ -14,9 +14,8 @@ * limitations under the License. */ -package com.example.jetcaster.core.data.model +package com.example.jetcaster.core.model -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import java.time.Duration import java.time.OffsetDateTime @@ -46,16 +45,3 @@ data class PlayerEpisode( uri = episodeInfo.uri ) } - -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 ?: "", - ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastCategoryFilterResult.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt similarity index 75% rename from Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastCategoryFilterResult.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt index dbc39daddf..e1d27306ed 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastCategoryFilterResult.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt @@ -14,9 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.core.data.model - -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +package com.example.jetcaster.core.model /** * A model holding top podcasts and matching episodes when filtering based on a category. @@ -30,9 +28,3 @@ data class PodcastCategoryEpisode( val episode: EpisodeInfo, val podcast: PodcastInfo, ) - -fun EpisodeToPodcast.asPodcastCategoryEpisode(): PodcastCategoryEpisode = - PodcastCategoryEpisode( - episode = episode.asExternalModel(), - podcast = podcast.asExternalModel(), - ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt similarity index 61% rename from Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt index 147a840944..5aced90656 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PodcastInfo.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt @@ -14,10 +14,8 @@ * limitations under the License. */ -package com.example.jetcaster.core.data.model +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 /** @@ -32,18 +30,3 @@ data class PodcastInfo( 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, - ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt index 4dff2871ef..4b90f4b1c8 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt @@ -21,6 +21,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import com.example.jetcaster.core.model.CategoryInfo @Entity( tableName = "categories", @@ -33,3 +34,9 @@ data class Category( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "name") val name: String ) + +fun Category.asExternalModel() = + CategoryInfo( + id = id, + name = name + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt index 6a035d9646..cf9ae998e5 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt @@ -22,6 +22,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import com.example.jetcaster.core.model.EpisodeInfo import java.time.Duration import java.time.OffsetDateTime @@ -52,3 +53,14 @@ data class Episode( @ColumnInfo(name = "published") val published: OffsetDateTime, @ColumnInfo(name = "duration") val duration: Duration? = null ) + +fun Episode.asExternalModel(): EpisodeInfo = + EpisodeInfo( + uri = uri, + title = title, + subTitle = subtitle ?: "", + summary = summary ?: "", + author = author ?: "", + published = published, + duration = duration, + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt index 7945f20316..4646849aca 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt @@ -19,6 +19,8 @@ package com.example.jetcaster.core.data.database.model import androidx.room.Embedded import androidx.room.Ignore import androidx.room.Relation +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.model.PodcastCategoryEpisode import java.util.Objects class EpisodeToPodcast { @@ -46,3 +48,22 @@ class EpisodeToPodcast { override fun hashCode(): Int = Objects.hash(episode, _podcasts) } + +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 ?: "", + ) + +fun EpisodeToPodcast.asPodcastCategoryEpisode(): PodcastCategoryEpisode = + PodcastCategoryEpisode( + episode = episode.asExternalModel(), + podcast = podcast.asExternalModel(), + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt index 1d86f31f91..642759db3c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt @@ -21,6 +21,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import com.example.jetcaster.core.model.PodcastInfo @Entity( tableName = "podcasts", @@ -37,3 +38,12 @@ data class Podcast( @ColumnInfo(name = "image_url") val imageUrl: String? = null, @ColumnInfo(name = "copyright") val copyright: String? = null ) + +fun Podcast.asExternalModel(): PodcastInfo = + PodcastInfo( + uri = this.uri, + title = this.title, + author = this.author ?: "", + imageUrl = this.imageUrl ?: "", + description = this.description ?: "", + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt index 8794a46e47..e76c4b22f2 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt @@ -18,6 +18,7 @@ package com.example.jetcaster.core.data.database.model import androidx.room.ColumnInfo import androidx.room.Embedded +import com.example.jetcaster.core.model.PodcastInfo import java.time.OffsetDateTime import java.util.Objects @@ -50,3 +51,9 @@ class PodcastWithExtraInfo { override fun hashCode(): Int = Objects.hash(podcast, lastEpisodeDate, isFollowed) } + +fun PodcastWithExtraInfo.asExternalModel(): PodcastInfo = + this.podcast.asExternalModel().copy( + isSubscribed = isFollowed, + lastEpisodeDate = lastEpisodeDate, + ) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt index c7255b9466..575ded495e 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt @@ -16,9 +16,9 @@ package com.example.jetcaster.core.data.domain -import com.example.jetcaster.core.data.model.CategoryInfo -import com.example.jetcaster.core.data.model.FilterableCategoriesModel -import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.database.model.asExternalModel +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel import com.example.jetcaster.core.data.repository.CategoryStore import javax.inject.Inject import kotlinx.coroutines.flow.Flow diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt index 68aa0d22a6..226bf44e59 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt @@ -17,15 +17,15 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.core.data.model.CategoryInfo -import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.core.data.model.asExternalModel -import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode +import com.example.jetcaster.core.data.database.model.asExternalModel +import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.CategoryStore -import javax.inject.Inject +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject /** * A use case which returns top podcasts and matching episodes in a given [Category]. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt index 648e10ec6b..d6c84ebe42 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -16,9 +16,9 @@ package com.example.jetcaster.core.player -import com.example.jetcaster.core.data.model.PlayerEpisode -import java.time.Duration +import com.example.jetcaster.core.model.PlayerEpisode import kotlinx.coroutines.flow.StateFlow +import java.time.Duration data class EpisodePlayerState( val currentEpisode: PlayerEpisode? = null, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 72c857eec3..a33b308b14 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -16,7 +16,7 @@ package com.example.jetcaster.core.player -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import java.time.Duration import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineDispatcher diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt index 13b5cb4533..f2d0c63971 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt @@ -17,7 +17,7 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.model.asExternalModel import com.example.jetcaster.core.data.repository.TestCategoryStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index bb20c1915b..17ca206ccd 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -21,8 +21,8 @@ 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.model.asExternalModel -import com.example.jetcaster.core.data.model.asPodcastCategoryEpisode +import com.example.jetcaster.model.asExternalModel +import com.example.jetcaster.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore import java.time.OffsetDateTime import kotlinx.coroutines.flow.first diff --git a/Jetcaster/designsystem/build.gradle.kts b/Jetcaster/designsystem/build.gradle.kts index ce86e815dc..7fdf99b1fd 100644 --- a/Jetcaster/designsystem/build.gradle.kts +++ b/Jetcaster/designsystem/build.gradle.kts @@ -21,6 +21,16 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -34,6 +44,7 @@ dependencies { 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) diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt new file mode 100644 index 0000000000..96129aac8b --- /dev/null +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt @@ -0,0 +1,72 @@ +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, blendMode = BlendMode.Multiply) + } + ) +} + +@Composable +fun ImageBackgroundRadialGradientScrim( + url: String?, + colors: List, + 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() + } + } + ) +} diff --git a/Jetcaster/settings.gradle.kts b/Jetcaster/settings.gradle.kts index f25709ae64..a0016bc0dd 100644 --- a/Jetcaster/settings.gradle.kts +++ b/Jetcaster/settings.gradle.kts @@ -35,5 +35,5 @@ dependencyResolutionManagement { } } rootProject.name = "Jetcaster" -include(":app", ":core", ":designsystem", ":tv-app", ":wear") +include(":app", ":core", ":core:model", ":designsystem", ":tv-app", ":wear") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") diff --git a/Jetcaster/tv-app/build.gradle.kts b/Jetcaster/tv-app/build.gradle.kts index c94a090d89..8264edad44 100644 --- a/Jetcaster/tv-app/build.gradle.kts +++ b/Jetcaster/tv-app/build.gradle.kts @@ -76,7 +76,6 @@ dependencies { implementation(libs.androidx.tv.foundation) 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) implementation(libs.coil.kt.compose) @@ -84,8 +83,10 @@ dependencies { // Dependency injection implementation(libs.androidx.hilt.navigation.compose) implementation(libs.hilt.android) + implementation(project(":core:model")) ksp(libs.hilt.compiler) + implementation(project(":core")) implementation(project(":designsystem")) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt index 3f2264b332..752cbdf3f7 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt @@ -19,74 +19,35 @@ 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.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment 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 import com.example.jetcaster.core.data.database.model.Podcast -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.ImageBackgroundRadialGradientScrim @Composable internal fun Background( podcast: Podcast, modifier: Modifier = Modifier, - overlay: DrawScope.() -> Unit = { - val brush = Brush.radialGradient( - listOf(Color.Black, Color.Transparent), - center = Offset(0f, size.height), - radius = size.width * 1.5f - ) - drawRect(brush, blendMode = BlendMode.Multiply) - } -) = Background(imageUrl = podcast.imageUrl, modifier, overlay) +) = Background(imageUrl = podcast.imageUrl, modifier) @Composable internal fun Background( episode: PlayerEpisode, modifier: Modifier = Modifier, - overlay: DrawScope.() -> Unit = { - val brush = Brush.radialGradient( - listOf(Color.Black, Color.Transparent), - center = Offset(0f, size.height), - radius = size.width * 1.5f - ) - drawRect(brush, blendMode = BlendMode.Multiply) - } -) = Background(imageUrl = episode.podcastImageUrl, modifier, overlay) +) = Background(imageUrl = episode.podcastImageUrl, modifier) @Composable internal fun Background( imageUrl: String?, modifier: Modifier = Modifier, - overlay: DrawScope.() -> Unit = { - val brush = Brush.radialGradient( - listOf(Color.Black, Color.Transparent), - center = Offset(0f, size.height), - radius = size.width * 1.5f - ) - drawRect(brush, blendMode = BlendMode.Multiply) - } ) { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - .fillMaxWidth() - .drawWithCache { - onDrawWithContent { - drawContent() - overlay() - } - } + ImageBackgroundRadialGradientScrim( + url = imageUrl, + colors = listOf(Color.Black, Color.Transparent), + modifier = modifier, ) } @@ -94,19 +55,11 @@ internal fun Background( internal fun BackgroundContainer( playerEpisode: PlayerEpisode, modifier: Modifier = Modifier, - overlay: DrawScope.() -> Unit = { - val brush = Brush.radialGradient( - listOf(Color.Black, Color.Transparent), - center = Offset(0f, size.height), - radius = size.width * 1.5f - ) - drawRect(brush, blendMode = BlendMode.Multiply) - }, contentAlignment: Alignment = Alignment.Center, content: @Composable BoxScope.() -> Unit ) { Box(modifier = modifier, contentAlignment = contentAlignment) { - Background(episode = playerEpisode, overlay = overlay, modifier = Modifier.fillMaxSize()) + Background(episode = playerEpisode, modifier = Modifier.fillMaxSize()) content() } } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt index 8150fd77af..c076e27f12 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -32,7 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Text import com.example.jetcaster.R -import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.model.PodcastInfo import com.google.android.horologist.composables.PlaceholderChip import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults @@ -74,7 +74,7 @@ fun HomeScreen( onLatestEpisodeClick: () -> Unit, onYourPodcastClick: () -> Unit, onUpNextClick: () -> Unit, - onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onTogglePodcastFollowed: (com.example.jetcaster.model.PodcastInfo) -> Unit, modifier: Modifier = Modifier, ) { val columnState = rememberResponsiveColumnState( @@ -156,7 +156,7 @@ fun HomeScreen( } @Composable private fun PodcastContent( - podcast: PodcastInfo, + podcast: com.example.jetcaster.model.PodcastInfo, downloadItemArtworkPlaceholder: Painter?, onClick: () -> Unit ) { diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 513a83b0fe..4e652711f3 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -23,10 +23,10 @@ import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase -import com.example.jetcaster.core.data.model.CategoryInfo -import com.example.jetcaster.core.data.model.FilterableCategoriesModel -import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.model.CategoryInfo +import com.example.jetcaster.model.FilterableCategoriesModel +import com.example.jetcaster.model.PodcastCategoryFilterResult +import com.example.jetcaster.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository @@ -59,7 +59,7 @@ class HomeViewModel @Inject constructor( // Holds the currently available home categories private val homeCategories = MutableStateFlow(HomeCategory.entries) // Holds our currently selected category - private val _selectedCategory = MutableStateFlow(null) + private val _selectedCategory = MutableStateFlow(null) // Holds the view state if the UI is refreshing for new data private val refreshing = MutableStateFlow(false) @@ -149,8 +149,8 @@ data class HomeViewState( val refreshing: Boolean = false, val selectedHomeCategory: HomeCategory = HomeCategory.Discover, val homeCategories: List = emptyList(), - val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), - val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), + val filterableCategoriesModel: com.example.jetcaster.model.FilterableCategoriesModel = com.example.jetcaster.model.FilterableCategoriesModel(), + val podcastCategoryFilterResult: com.example.jetcaster.model.PodcastCategoryFilterResult = com.example.jetcaster.model.PodcastCategoryFilterResult(), val libraryEpisodes: List = emptyList(), val errorMessage: String? = null ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index c270b83c40..751f24d3aa 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel @@ -67,7 +67,7 @@ class LatestEpisodeViewModel @Inject constructor( } } } - fun onPlayEpisode(episode: PlayerEpisode) { + fun onPlayEpisode(episode: com.example.jetcaster.model.PlayerEpisode) { episodePlayer.currentEpisode = episode episodePlayer.play() } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt index 58e4042aa7..dbef598083 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt @@ -38,8 +38,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.model.PlayerEpisode +import com.example.jetcaster.model.toPlayerEpisode import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.compose.layout.rememberColumnState @@ -75,7 +75,7 @@ fun LatestEpisodeScreen( onShuffleButtonClick: (List) -> Unit, onPlayButtonClick: (List) -> Unit, modifier: Modifier = Modifier, - onPlayEpisode: (PlayerEpisode) -> Unit, + onPlayEpisode: (com.example.jetcaster.model.PlayerEpisode) -> Unit, ) { val columnState = rememberColumnState() ScreenScaffold( @@ -134,7 +134,7 @@ fun ButtonsContent( viewState: LatestEpisodeViewState, onShuffleButtonClick: (List) -> Unit, onPlayButtonClick: (List) -> Unit, - onPlayEpisode: (PlayerEpisode) -> Unit + onPlayEpisode: (com.example.jetcaster.model.PlayerEpisode) -> Unit ) { Row( From 3832b73832d6af4e6bec82e7067a34dfc9837d55 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 10 Apr 2024 12:35:34 -0700 Subject: [PATCH 117/143] Fix wear model references. --- Jetcaster/wear/build.gradle | 1 + .../com/example/jetcaster/ui/home/HomeScreen.kt | 6 +++--- .../example/jetcaster/ui/home/HomeViewModel.kt | 16 ++++++++-------- .../ui/library/LatestEpisodeViewModel.kt | 6 +++--- .../jetcaster/ui/library/LatestEpisodesScreen.kt | 8 ++++---- .../jetcaster/ui/library/PodcastsScreen.kt | 2 +- .../jetcaster/ui/library/PodcastsViewModel.kt | 4 ++-- 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index 72cf8f8436..8736969a88 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -75,6 +75,7 @@ android { dependencies { + implementation project(':core:model') def composeBom = platform(libs.androidx.compose.bom) // General compose dependencies diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt index c076e27f12..aabff8a686 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -32,7 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Text import com.example.jetcaster.R -import com.example.jetcaster.model.PodcastInfo +import com.example.jetcaster.core.model.PodcastInfo import com.google.android.horologist.composables.PlaceholderChip import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults @@ -74,7 +74,7 @@ fun HomeScreen( onLatestEpisodeClick: () -> Unit, onYourPodcastClick: () -> Unit, onUpNextClick: () -> Unit, - onTogglePodcastFollowed: (com.example.jetcaster.model.PodcastInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, modifier: Modifier = Modifier, ) { val columnState = rememberResponsiveColumnState( @@ -156,7 +156,7 @@ fun HomeScreen( } @Composable private fun PodcastContent( - podcast: com.example.jetcaster.model.PodcastInfo, + podcast: PodcastInfo, downloadItemArtworkPlaceholder: Painter?, onClick: () -> Unit ) { diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 4e652711f3..fd95cfd838 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -21,19 +21,18 @@ import androidx.lifecycle.viewModelScope 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.database.model.toPlayerEpisode import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase -import com.example.jetcaster.model.CategoryInfo -import com.example.jetcaster.model.FilterableCategoriesModel -import com.example.jetcaster.model.PodcastCategoryFilterResult -import com.example.jetcaster.model.toPlayerEpisode 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.CategoryInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.PodcastCategoryFilterResult import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -41,6 +40,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -59,7 +59,7 @@ class HomeViewModel @Inject constructor( // Holds the currently available home categories private val homeCategories = MutableStateFlow(HomeCategory.entries) // Holds our currently selected category - private val _selectedCategory = MutableStateFlow(null) + private val _selectedCategory = MutableStateFlow(null) // Holds the view state if the UI is refreshing for new data private val refreshing = MutableStateFlow(false) @@ -149,8 +149,8 @@ data class HomeViewState( val refreshing: Boolean = false, val selectedHomeCategory: HomeCategory = HomeCategory.Discover, val homeCategories: List = emptyList(), - val filterableCategoriesModel: com.example.jetcaster.model.FilterableCategoriesModel = com.example.jetcaster.model.FilterableCategoriesModel(), - val podcastCategoryFilterResult: com.example.jetcaster.model.PodcastCategoryFilterResult = com.example.jetcaster.model.PodcastCategoryFilterResult(), + val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), + val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), val libraryEpisodes: List = emptyList(), val errorMessage: String? = null ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index 751f24d3aa..9a5ca5027f 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -20,15 +20,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase -import com.example.jetcaster.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class LatestEpisodeViewModel @Inject constructor( @@ -67,7 +67,7 @@ class LatestEpisodeViewModel @Inject constructor( } } } - fun onPlayEpisode(episode: com.example.jetcaster.model.PlayerEpisode) { + fun onPlayEpisode(episode: PlayerEpisode) { episodePlayer.currentEpisode = episode episodePlayer.play() } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt index dbef598083..58f6f47fce 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt @@ -38,8 +38,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.model.PlayerEpisode -import com.example.jetcaster.model.toPlayerEpisode +import com.example.jetcaster.core.data.database.model.toPlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.compose.layout.rememberColumnState @@ -75,7 +75,7 @@ fun LatestEpisodeScreen( onShuffleButtonClick: (List) -> Unit, onPlayButtonClick: (List) -> Unit, modifier: Modifier = Modifier, - onPlayEpisode: (com.example.jetcaster.model.PlayerEpisode) -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit, ) { val columnState = rememberColumnState() ScreenScaffold( @@ -134,7 +134,7 @@ fun ButtonsContent( viewState: LatestEpisodeViewState, onShuffleButtonClick: (List) -> Unit, onPlayButtonClick: (List) -> Unit, - onPlayEpisode: (com.example.jetcaster.model.PlayerEpisode) -> Unit + onPlayEpisode: (PlayerEpisode) -> Unit ) { Row( diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt index a78da459e7..328afba234 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt @@ -41,7 +41,7 @@ import androidx.wear.compose.material.Text import androidx.wear.compose.material.dialog.Alert import androidx.wear.compose.material.dialog.Dialog import com.example.jetcaster.R -import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.model.PodcastInfo import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.composables.PlaceholderChip import com.google.android.horologist.composables.Section diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt index e7bb50fb85..8b3d4d477e 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt @@ -19,8 +19,8 @@ package com.example.jetcaster.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo -import com.example.jetcaster.core.data.model.PodcastInfo -import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.database.model.asExternalModel +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.data.repository.PodcastStore import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject From 3e11672f7c7bd43cfa98f189f1fc1b3d1cc4dc3c Mon Sep 17 00:00:00 2001 From: arriolac Date: Wed, 10 Apr 2024 19:39:20 +0000 Subject: [PATCH 118/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/jetcaster/ui/home/Home.kt | 6 +++--- .../com/example/jetcaster/ui/home/PreviewData.kt | 2 +- .../ui/home/category/PodcastCategory.kt | 2 +- .../jetcaster/ui/home/discover/Discover.kt | 2 +- .../example/jetcaster/ui/home/library/Library.kt | 2 +- .../jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../data/domain/FilterableCategoriesUseCase.kt | 2 +- .../data/domain/PodcastCategoryFilterUseCase.kt | 2 +- .../jetcaster/core/player/EpisodePlayer.kt | 2 +- .../domain/FilterableCategoriesUseCaseTest.kt | 2 +- .../domain/PodcastCategoryFilterUseCaseTest.kt | 2 +- .../designsystem/component/ImageBackground.kt | 16 ++++++++++++++++ .../example/jetcaster/ui/home/HomeViewModel.kt | 2 +- .../ui/library/LatestEpisodeViewModel.kt | 2 +- .../jetcaster/ui/library/PodcastsViewModel.kt | 2 +- 15 files changed, 33 insertions(+), 17 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index ffd23417a9..dee51f23da 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -124,12 +124,12 @@ 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 kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch 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 data class HomeState( val windowSizeClass: WindowSizeClass, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index 2341e7ac82..f4ef9e6df5 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -50,7 +50,7 @@ val PreviewEpisodes = listOf( 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!", + "Tsurkan from the System UI team about... Bubbles!", published = OffsetDateTime.of( 2020, 6, 2, 9, 27, 0, 0, ZoneOffset.of("-0800") diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index 6fc7607bba..29c6e15115 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -46,11 +46,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.core.model.EpisodeInfo import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.model.PodcastCategoryFilterResult import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.shared.EpisodeListItem diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 2ff5b0e1e0..f8b8884cf9 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -38,13 +38,13 @@ 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.designsystem.theme.Keyline1 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.PlayerEpisode import com.example.jetcaster.core.model.PodcastCategoryFilterResult import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.category.podcastCategory import com.example.jetcaster.util.fullWidthItem diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index e27ad745a4..f425042ae4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -28,10 +28,10 @@ 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.designsystem.theme.Keyline1 import com.example.jetcaster.core.model.EpisodeInfo import com.example.jetcaster.core.model.LibraryInfo import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.shared.EpisodeListItem import com.example.jetcaster.util.fullWidthItem diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 2dfdfd401f..73804e2177 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -29,12 +29,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState 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 -import java.time.Duration -import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt index 575ded495e..cd55b68a8a 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt @@ -17,9 +17,9 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.asExternalModel +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.data.repository.CategoryStore import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt index 226bf44e59..71e3d160a3 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.kt @@ -22,10 +22,10 @@ import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.CategoryStore import com.example.jetcaster.core.model.CategoryInfo import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf -import javax.inject.Inject /** * A use case which returns top podcasts and matching episodes in a given [Category]. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt index d6c84ebe42..173ac5eb73 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -17,8 +17,8 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode -import kotlinx.coroutines.flow.StateFlow import java.time.Duration +import kotlinx.coroutines.flow.StateFlow data class EpisodePlayerState( val currentEpisode: PlayerEpisode? = null, diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt index f2d0c63971..3f76041790 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt @@ -17,8 +17,8 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.model.asExternalModel import com.example.jetcaster.core.data.repository.TestCategoryStore +import com.example.jetcaster.model.asExternalModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index 17ca206ccd..c8065424b4 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -21,9 +21,9 @@ 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.repository.TestCategoryStore import com.example.jetcaster.model.asExternalModel import com.example.jetcaster.model.asPodcastCategoryEpisode -import com.example.jetcaster.core.data.repository.TestCategoryStore import java.time.OffsetDateTime import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt index 96129aac8b..83670bf6a5 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index fd95cfd838..c3538e99f2 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -33,6 +33,7 @@ import com.example.jetcaster.core.model.PodcastCategoryFilterResult import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -40,7 +41,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index 9a5ca5027f..366602bd64 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -24,11 +24,11 @@ import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class LatestEpisodeViewModel @Inject constructor( diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt index 8b3d4d477e..5e18dc1ebd 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt @@ -20,8 +20,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.database.model.asExternalModel -import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.PodcastInfo import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted From 8ebfbaaa35c825ecb08bea59ffa83a4188fbcf07 Mon Sep 17 00:00:00 2001 From: Ivy Knight Date: Wed, 10 Apr 2024 15:33:37 -0700 Subject: [PATCH 119/143] Update README.md images --- Jetcaster/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Jetcaster/README.md b/Jetcaster/README.md index 0fbbe55c11..d48e2b9684 100644 --- a/Jetcaster/README.md +++ b/Jetcaster/README.md @@ -19,7 +19,7 @@ most of the app's architecture has been implemented as well as the data layer. ## Screenshots - +![readme_cover](https://github.com/android/compose-samples/assets/10263978/a58ab950-71aa-48e0-8bc7-85443a1b4f6b) ## Features @@ -34,7 +34,8 @@ The home screen is split into sub-screens for easy re-use: The player screen displays media controls and the currently "playing" podcast (the sample currently doesn't actually play any media). The player screen layout is adapting to different form factors, including a tabletop layout on foldable devices: - +![readme_fold](https://github.com/android/compose-samples/assets/10263978/fe02248f-81ce-489b-a6d6-838438c8368e) + ### Others Some other notable things which are implemented: From 9c2802ebbedc82431e75e7f40f9535e7f215f6bd Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 10 Apr 2024 15:37:26 -0700 Subject: [PATCH 120/143] [Jetcaster] Fix TV imports. --- .../core/data/domain/FilterableCategoriesUseCaseTest.kt | 2 +- .../core/data/domain/PodcastCategoryFilterUseCaseTest.kt | 6 +++--- Jetcaster/tv-app/build.gradle.kts | 1 + .../main/java/com/example/jetcaster/tv/model/EpisodeList.kt | 2 +- .../java/com/example/jetcaster/tv/ui/JetcasterAppState.kt | 2 +- .../com/example/jetcaster/tv/ui/component/EpisodeCard.kt | 4 ++-- .../com/example/jetcaster/tv/ui/component/EpisodeDetails.kt | 2 +- .../java/com/example/jetcaster/tv/ui/component/Thumbnail.kt | 2 +- .../com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt | 4 ++-- .../jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt | 4 ++-- .../java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt | 4 ++-- .../example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt | 2 +- 12 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt index 3f76041790..1a548197ea 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt @@ -17,8 +17,8 @@ package com.example.jetcaster.core.data.domain import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.asExternalModel import com.example.jetcaster.core.data.repository.TestCategoryStore -import com.example.jetcaster.model.asExternalModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index c8065424b4..d0cb16c0aa 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -21,15 +21,15 @@ 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.database.model.asExternalModel +import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore -import com.example.jetcaster.model.asExternalModel -import com.example.jetcaster.model.asPodcastCategoryEpisode -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 +import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { diff --git a/Jetcaster/tv-app/build.gradle.kts b/Jetcaster/tv-app/build.gradle.kts index 8264edad44..7448b4364f 100644 --- a/Jetcaster/tv-app/build.gradle.kts +++ b/Jetcaster/tv-app/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { implementation(libs.androidx.tv.foundation) 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) implementation(libs.coil.kt.compose) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt index 96bf5b25bd..04a2f20974 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -18,7 +18,7 @@ package com.example.jetcaster.tv.model import androidx.compose.runtime.Immutable import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode @Immutable data class EpisodeList(val member: List) : List by member diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt index 120e801a84..c818dfa568 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -21,7 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode class JetcasterAppState( val navHostController: NavHostController diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt index 155dde6895..0976f08218 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt @@ -36,8 +36,8 @@ import androidx.tv.material3.Text import androidx.tv.material3.WideCardLayout import coil.compose.AsyncImage import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.core.data.database.model.toPlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt index a646766d17..6fb101fc71 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.text.TextStyle import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt index a64f32141c..88b37b49d6 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.example.jetcaster.core.data.database.model.Podcast -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt index 533e23d8ea..619ba71482 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -36,8 +36,8 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.core.data.database.model.Episode import com.example.jetcaster.core.data.database.model.EpisodeToPodcast -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.core.data.database.model.toPlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.ui.component.Background import com.example.jetcaster.tv.ui.component.EnqueueButton import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index aa4bc24576..3b96f178b1 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -20,13 +20,12 @@ 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.model.PlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer 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 @@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index e215d47952..d52e8ad9ac 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -63,7 +63,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.PlayerEpisodeList @@ -80,9 +80,9 @@ 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 +import java.time.Duration @Composable fun PlayerScreen( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt index f8f8636b34..f41330b5f8 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt @@ -18,7 +18,7 @@ package com.example.jetcaster.tv.ui.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import dagger.hilt.android.lifecycle.HiltViewModel From 6743bff185d696aca98f07da42bec5a5e824c779 Mon Sep 17 00:00:00 2001 From: arriolac Date: Wed, 10 Apr 2024 22:42:37 +0000 Subject: [PATCH 121/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/domain/PodcastCategoryFilterUseCaseTest.kt | 2 +- .../example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt | 2 +- .../java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index d0cb16c0aa..2f2d5a3b5b 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -24,12 +24,12 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.database.model.asExternalModel import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore +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 -import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index 3b96f178b1..9974d49952 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -26,6 +26,7 @@ import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer 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 @@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index d52e8ad9ac..7fee1b3c3e 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -80,9 +80,9 @@ 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 -import java.time.Duration @Composable fun PlayerScreen( From efc00313536f524c0157ba0e8a42fcc749c6c074 Mon Sep 17 00:00:00 2001 From: chikoski Date: Thu, 11 Apr 2024 01:25:40 +0000 Subject: [PATCH 122/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt index 264a871341..ad19b2c0d7 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -17,7 +17,6 @@ package com.example.jetcaster.tv.model import androidx.compose.runtime.Immutable -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.model.PlayerEpisode @Immutable From 844e89d307d4848973ef66a821f8a295357066dc Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Thu, 11 Apr 2024 11:02:50 -0700 Subject: [PATCH 123/143] [Jetcaster]: Fix imports. --- .../java/com/example/jetcaster/tv/ui/component/Catalog.kt | 2 +- .../java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt | 2 +- .../com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt | 2 +- .../jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt | 4 ++-- .../java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt | 2 +- .../example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt | 4 ++-- .../java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt | 2 +- .../example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index b6971fed71..1a3a03f0ff 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -42,7 +42,7 @@ import androidx.tv.material3.Text import coil.compose.AsyncImage import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt index 29623a931d..8690cc7b71 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.tv.foundation.lazy.list.TvLazyRow import androidx.tv.foundation.lazy.list.itemsIndexed -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 037ddaa25c..49ea74414b 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -39,7 +39,7 @@ import androidx.tv.material3.Text 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.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 8609d73397..44b638aad4 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -19,10 +19,10 @@ package com.example.jetcaster.tv.ui.discover import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Category -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.core.data.database.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.CategoryStore import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index 9073084fe4..84cc659c69 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -37,7 +37,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index 426fe861ea..488b5c2da8 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -18,11 +18,11 @@ package com.example.jetcaster.tv.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.core.data.database.model.toPlayerEpisode 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.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index eeef870c77..ddc0ce32c5 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -59,7 +59,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.core.data.database.model.Podcast -import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.ui.component.Background diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt index fcf087ffbe..ace9275b0c 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -20,10 +20,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Podcast -import com.example.jetcaster.core.data.model.PlayerEpisode -import com.example.jetcaster.core.data.model.toPlayerEpisode +import com.example.jetcaster.core.data.database.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.ui.Screen From df2cd689e753919c7881bbd89aa83627a3fa35ce Mon Sep 17 00:00:00 2001 From: Jonathan Koren Date: Wed, 10 Apr 2024 15:47:09 -0700 Subject: [PATCH 124/143] Unify WindowSizeClass usage --- .../com/example/jetcaster/ui/JetcasterApp.kt | 12 +++++---- .../com/example/jetcaster/ui/MainActivity.kt | 5 ---- .../com/example/jetcaster/ui/home/Home.kt | 26 ++++++------------- .../jetcaster/ui/player/PlayerScreen.kt | 11 +++----- .../example/jetcaster/util/WindowSizeClass.kt | 10 +++---- 5 files changed, 24 insertions(+), 40 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 93f63a9fa6..15c9472cdd 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -19,7 +19,8 @@ package com.example.jetcaster.ui import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.navigation.compose.NavHost @@ -29,12 +30,13 @@ import com.example.jetcaster.R import com.example.jetcaster.ui.home.MainScreen import com.example.jetcaster.ui.player.PlayerScreen +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun JetcasterApp( - windowSizeClass: WindowSizeClass, displayFeatures: List, appState: JetcasterAppState = rememberJetcasterAppState() ) { + val adaptiveInfo = currentWindowAdaptiveInfo() if (appState.isOnline) { NavHost( navController = appState.navController, @@ -42,7 +44,7 @@ fun JetcasterApp( ) { composable(Screen.Home.route) { backStackEntry -> MainScreen( - windowSizeClass = windowSizeClass, + windowSizeClass = adaptiveInfo.windowSizeClass, navigateToPlayer = { episode -> appState.navigateToPlayer(episode.uri, backStackEntry) } @@ -50,8 +52,8 @@ fun JetcasterApp( } composable(Screen.Player.route) { PlayerScreen( - windowSizeClass, - displayFeatures, + windowSizeClass = adaptiveInfo.windowSizeClass, + displayFeatures = displayFeatures, onBackPress = appState::navigateBack ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt index 5e41c34b1b..8e777683da 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt @@ -20,27 +20,22 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import com.example.jetcaster.ui.theme.JetcasterTheme import com.google.accompanist.adaptive.calculateDisplayFeatures import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - val windowSizeClass = calculateWindowSizeClass(this) val displayFeatures = calculateDisplayFeatures(this) JetcasterTheme { JetcasterApp( - windowSizeClass, displayFeatures ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index b91d501cf9..6328a0eba6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -81,9 +81,6 @@ 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.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -101,11 +98,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.model.CategoryInfo @@ -158,9 +155,9 @@ private val HomeState.showHomeCategoryTabs: Boolean @OptIn(ExperimentalMaterial3AdaptiveApi::class) private fun HomeState.showGrid( scaffoldValue: ThreePaneScaffoldValue -): Boolean = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || +): Boolean = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED || ( - windowSizeClass.widthSizeClass == WindowWidthSizeClass.Medium && + windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM && scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden ) @@ -180,17 +177,17 @@ fun calculateScaffoldDirective( ): PaneScaffoldDirective { val maxHorizontalPartitions: Int val verticalSpacerSize: Dp - if (windowAdaptiveInfo.windowSizeClass.isCompact()) { + 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) { - androidx.window.core.layout.WindowWidthSizeClass.COMPACT -> { + WindowWidthSizeClass.COMPACT -> { maxHorizontalPartitions = 1 verticalSpacerSize = 0.dp } - androidx.window.core.layout.WindowWidthSizeClass.MEDIUM -> { + WindowWidthSizeClass.MEDIUM -> { maxHorizontalPartitions = 1 verticalSpacerSize = 0.dp } @@ -236,10 +233,6 @@ private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy } } -private fun androidx.window.core.layout.WindowSizeClass.isCompact(): Boolean = - windowWidthSizeClass == androidx.window.core.layout.WindowWidthSizeClass.COMPACT || - windowHeightSizeClass == WindowHeightSizeClass.COMPACT - @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun MainScreen( @@ -875,10 +868,7 @@ private fun HomeAppBarPreview() { } } -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) -private val CompactWindowSizeClass = WindowSizeClass.calculateFromSize( - size = DpSize(width = 360.dp, height = 780.dp) -) +private val CompactWindowSizeClass = WindowSizeClass.compute(360f, 780f) @Preview(device = Devices.PHONE) @Composable diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 67c74d98a7..3346af23e4 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -59,9 +59,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment @@ -80,9 +77,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize 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 coil.compose.AsyncImage @@ -235,7 +233,7 @@ fun PlayerContent( // 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.widthSizeClass == WindowWidthSizeClass.Expanded || + windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED || isBookPosture(foldingFeature) || isTableTopPosture(foldingFeature) || isSeparatingPosture(foldingFeature) @@ -803,7 +801,6 @@ fun PlayerButtonsPreview() { } } -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview(device = Devices.PHONE) @Preview(device = Devices.FOLDABLE) @Preview(device = Devices.TABLET) @@ -824,7 +821,7 @@ fun PlayerScreenPreview() { ), ), displayFeatures = emptyList(), - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), + windowSizeClass = WindowSizeClass.compute(maxWidth.value, maxHeight.value), onBackPress = { }, onPlayPress = {}, onPausePress = {}, diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt index a115739d2b..b4c90b3729 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt @@ -16,13 +16,13 @@ package com.example.jetcaster.util -import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +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() = widthSizeClass == WindowWidthSizeClass.Compact || - heightSizeClass == WindowHeightSizeClass.Compact + get() = windowWidthSizeClass == WindowWidthSizeClass.COMPACT || + windowHeightSizeClass == WindowHeightSizeClass.COMPACT From ef15624c7dc2a0c7b969ea1311f1ff1478eb3454 Mon Sep 17 00:00:00 2001 From: Jonathan Koren Date: Thu, 11 Apr 2024 10:25:15 -0700 Subject: [PATCH 125/143] Remove material3-window-size-class dependency --- Jetcaster/app/build.gradle.kts | 1 - Jetcaster/gradle/libs.versions.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index eae707a950..5d6a17a8ee 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -110,7 +110,6 @@ dependencies { implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.navigation) - implementation(libs.androidx.compose.material3.window) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index b5647a547d..a3e9811f80 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -89,7 +89,6 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidx-compose-material3-adaptive" } androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "androidx-compose-material3-adaptive" } androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "androidx-compose-material3-adaptive" } -androidx-compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } androidx-compose-ui = { module = "androidx.compose.ui:ui"} From bc6e74b6cbafada96f4a49077f4930c8851286a9 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Fri, 12 Apr 2024 14:59:38 +0900 Subject: [PATCH 126/143] [Jetcaster] Refactoring HTML Elements in SummaryText --- .../jetcaster/ui/player/PlayerScreen.kt | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index f5a3ae922b..3ce61c5e45 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ContentAlpha @@ -77,12 +78,14 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import coil.compose.AsyncImage @@ -332,19 +335,16 @@ private fun PlayerContentBookStart( .fillMaxSize() .verticalScroll(rememberScrollState()) .padding( - vertical = 8.dp, + vertical = 40.dp, horizontal = 16.dp ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceAround ) { - Spacer(modifier = Modifier.height(32.dp)) PodcastInformation( - uiState.title, - uiState.podcastName, - uiState.summary + title = uiState.title, + name = uiState.podcastName, + summary = uiState.summary, ) - Spacer(modifier = Modifier.height(32.dp)) } } @@ -445,12 +445,13 @@ private fun PodcastInformation( title: String, name: String, summary: String, + modifier: Modifier = Modifier, titleTextStyle: TextStyle = MaterialTheme.typography.h5, nameTextStyle: TextStyle = MaterialTheme.typography.h3, ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 8.dp) + modifier = modifier.padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(32.dp) ) { Text( text = name, @@ -458,21 +459,18 @@ private fun PodcastInformation( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(32.dp)) Text( text = title, style = titleTextStyle, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(32.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = summary, - style = MaterialTheme.typography.body2, - ) - } - Spacer(modifier = Modifier.weight(1f)) + HtmlText( + text = summary, + style = MaterialTheme.typography.body2.copy( + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium) + ), + ) } } @@ -587,6 +585,23 @@ private fun FullScreenLoading(modifier: Modifier = Modifier) { } } +@Composable +private fun HtmlText( + text: String, + style: TextStyle, +) { + val annotationString = buildAnnotatedString { + val htmlCompat = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) + append(htmlCompat) + } + SelectionContainer { + Text( + text = annotationString, + style = style, + ) + } +} + @Preview @Composable fun TopAppBarPreview() { From 2767b3f530489d929c1404197507bf67420ba0b7 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 5 Apr 2024 15:20:11 -0700 Subject: [PATCH 127/143] [Jetcaster] Update README. --- Jetcaster/README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Jetcaster/README.md b/Jetcaster/README.md index d48e2b9684..645d2eaa3b 100644 --- a/Jetcaster/README.md +++ b/Jetcaster/README.md @@ -11,27 +11,25 @@ You can clone this repository or import the project from Android Studio following the steps [here](https://developer.android.com/jetpack/compose/setup#sample). -### Status: 🚧 In progress 🚧 - -Jetcaster is still in the early stages of development, and as such only one screen has been created so far. However, -most of the app's architecture has been implemented as well as the data layer. - - ## Screenshots ![readme_cover](https://github.com/android/compose-samples/assets/10263978/a58ab950-71aa-48e0-8bc7-85443a1b4f6b) ## Features -This sample contains 2 screens so far: the home screen, and a player screen. +This sample has 3 components: the home scree, 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 followed podcasts (top carousel), and navigate between 'Your Library' and 'Discover' +- __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. -The player screen displays media controls and the currently "playing" podcast (the sample currently doesn't actually play any media). +Multiple panes will also be shown depending on the device's [window size class][wsc]: + + + +The player screen displays media controls and the currently "playing" podcast (the sample currently **does not** actually play any media—the behavior is simply mocked). The player screen layout is adapting to different form factors, including a tabletop layout on foldable devices: ![readme_fold](https://github.com/android/compose-samples/assets/10263978/fe02248f-81ce-489b-a6d6-838438c8368e) @@ -117,3 +115,4 @@ limitations under the License. [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 From 8ce7a62488db0d1d4988f339aa548eaf122d725b Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 9 Apr 2024 13:39:25 -0700 Subject: [PATCH 128/143] Fix typo. --- Jetcaster/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jetcaster/README.md b/Jetcaster/README.md index 645d2eaa3b..fee9c9c5e2 100644 --- a/Jetcaster/README.md +++ b/Jetcaster/README.md @@ -3,7 +3,7 @@ # 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 and full featured architecture. +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). @@ -17,7 +17,7 @@ project from Android Studio following the steps ## Features -This sample has 3 components: the home scree, the podcast details screen, and the player screen +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: From 5a409b3d0dd7399c2c5c3b4a2b362c6b7d6f6610 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 09:43:32 -0700 Subject: [PATCH 129/143] Remove link to tablet. --- Jetcaster/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Jetcaster/README.md b/Jetcaster/README.md index fee9c9c5e2..0f8cef1423 100644 --- a/Jetcaster/README.md +++ b/Jetcaster/README.md @@ -25,9 +25,7 @@ The home screen is split into sub-screens for easy re-use: - __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]: - - +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: From af9038f5426dcb109630d3f8f837066f2073fc68 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 09:53:22 -0700 Subject: [PATCH 130/143] Update top-level README. --- README.md | 8 +++++++- readme/screenshots/Jetcaster.png | Bin 87597 -> 194072 bytes 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3370a85dc9..296b0f3fec 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,16 @@ Looking for a sample that has the following features? * [Jetchat: Downloadable Fonts](https://github.com/android/compose-samples/pull/787) ### Large Screens -* [Jetcaster - Tabletop mode](https://github.com/android/compose-samples/blob/0f7d5958c57a83ecad10136da4d359ae07046d07/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt#L138) +* [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) * [Crane - Window Size Classes](https://github.com/android/compose-samples/blob/e7e8733f9b37d80cdc6e9e05dbabe24ccf20b38f/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainActivity.kt#L72) +### 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` diff --git a/readme/screenshots/Jetcaster.png b/readme/screenshots/Jetcaster.png index ac0d2819752d01a6edcedef6932dcc37fed94c10..953351d1cec97934c774149c3f0ec9789dd54d66 100644 GIT binary patch literal 194072 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 literal 87597 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 Wgxl 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 From 129269a1ffb36ecc7bc27e046e27d548c64ce6ab Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Sat, 13 Apr 2024 03:01:21 +0900 Subject: [PATCH 131/143] [Jetcaster] Resolve conflicts - HtmlText --- .../com/example/jetcaster/ui/player/PlayerScreen.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index fcf7062ca2..f1eff04cb8 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Surface @@ -80,10 +81,10 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowWidthSizeClass -import androidx.core.text.HtmlCompat import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import coil.compose.AsyncImage @@ -640,9 +641,8 @@ private fun PodcastInformation( ) HtmlText( text = summary, - style = MaterialTheme.typography.body2.copy( - color = LocalContentColor.current.copy(alpha = ContentAlpha.medium) - ), + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current ) } } @@ -780,6 +780,7 @@ private fun FullScreenLoading(modifier: Modifier = Modifier) { private fun HtmlText( text: String, style: TextStyle, + color: Color ) { val annotationString = buildAnnotatedString { val htmlCompat = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) @@ -789,6 +790,7 @@ private fun HtmlText( Text( text = annotationString, style = style, + color = color ) } } From 5a82b731668084697f3d2508735820d59cadd9e0 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Sat, 13 Apr 2024 03:27:45 +0900 Subject: [PATCH 132/143] [Jetcaster] Fix PodcastInformation Center Alignment --- .../main/java/com/example/jetcaster/ui/player/PlayerScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index f1eff04cb8..9008507cfa 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -625,7 +625,8 @@ private fun PodcastInformation( ) { Column( modifier = modifier.padding(horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(32.dp) + verticalArrangement = Arrangement.spacedBy(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = name, From 8b3003b0e99a4152006db277a5865aec9396b3d6 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Wed, 10 Apr 2024 21:50:44 +0100 Subject: [PATCH 133/143] Adds PodcastDetailsScreen --- .../java/com/example/jetcaster/WearApp.kt | 21 +- .../jetcaster/ui/JetcasterNavController.kt | 23 ++ .../ui/library/LatestEpisodesScreen.kt | 33 +-- .../jetcaster/ui/library/PodcastsScreen.kt | 137 ++++++------ .../jetcaster/ui/library/PodcastsViewModel.kt | 2 +- .../ui/podcast/PodcastDetailsScreen.kt | 211 ++++++++++++++++++ .../ui/podcast/PodcastDetailsViewModel.kt | 102 +++++++++ .../wear/src/main/res/values/strings.xml | 1 + 8 files changed, 441 insertions(+), 89 deletions(-) create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt index be1b6201f8..92820f8204 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -42,14 +42,17 @@ import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState import com.example.jetcaster.theme.WearAppTheme 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.YourPodcasts import com.example.jetcaster.ui.home.HomeScreen import com.example.jetcaster.ui.library.LatestEpisodesScreen import com.example.jetcaster.ui.library.PodcastsScreen import com.example.jetcaster.ui.player.PlayerScreen +import com.example.jetcaster.ui.podcast.PodcastDetailsScreen import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume @@ -101,9 +104,8 @@ fun WearApp() { ) { LatestEpisodesScreen( playlistName = stringResource(id = R.string.latest_episodes), - onShuffleButtonClick = { - // navController.navigateToPlayer(it[0].episode.uri) - }, + // TODO implement change speed + onChangeSpeedButtonClick = {}, onPlayButtonClick = { navController.navigateToPlayer() } @@ -111,7 +113,18 @@ fun WearApp() { } composable(route = YourPodcasts.navRoute) { PodcastsScreen( - onPodcastsItemClick = { navController.navigateToPlayer() }, + onPodcastsItemClick = { navController.navigateToPodcastDetails(it.uri) }, + onErrorDialogCancelClick = { navController.popBackStack() } + ) + } + composable(route = PodcastDetails.navRoute) { + PodcastDetailsScreen( + // TODO implement change speed + onChangeSpeedButtonClick = {}, + onPlayButtonClick = { + navController.navigateToPlayer() + }, + onEpisodeItemClick = { navController.navigateToPlayer() }, onErrorDialogCancelClick = { navController.popBackStack() } ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt index c3b9c4d314..cad4aea90f 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt @@ -16,7 +16,11 @@ package com.example.jetcaster.ui +import android.net.Uri +import androidx.navigation.NamedNavArgument import androidx.navigation.NavController +import androidx.navigation.NavType +import androidx.navigation.navArgument import com.google.android.horologist.media.ui.navigation.NavigationScreens /** @@ -32,6 +36,10 @@ public object JetcasterNavController { navigate(LatestEpisodes.destination()) } + public fun NavController.navigateToPodcastDetails(podcastUri: String) { + navigate(PodcastDetails.destination(podcastUri)) + } + public fun NavController.navigateToUpNext() { navigate(UpNext.destination()) } @@ -45,6 +53,21 @@ public object LatestEpisodes : NavigationScreens("latestEpisodes") { public fun destination(): String = navRoute } +public object PodcastDetails : NavigationScreens("podcast?podcastUri={podcastUri}") { + public const val podcastUri: String = "podcastUri" + public fun destination(podcastUriValue: String): String { + val encodedUri = Uri.encode(podcastUriValue) + return "podcast?$podcastUri=$encodedUri" + } + + override val arguments: List + get() = listOf( + navArgument(podcastUri) { + type = NavType.StringType + }, + ) +} + public object UpNext : NavigationScreens("upNext") { public fun destination(): String = navRoute } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt index 58f6f47fce..1cd88a5aa1 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt @@ -52,8 +52,8 @@ import com.google.android.horologist.media.ui.screens.entity.EntityScreen @Composable fun LatestEpisodesScreen( playlistName: String, - onShuffleButtonClick: (List) -> Unit, - onPlayButtonClick: (List) -> Unit, + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, modifier: Modifier = Modifier, latestEpisodeViewModel: LatestEpisodeViewModel = hiltViewModel() ) { @@ -62,7 +62,7 @@ import com.google.android.horologist.media.ui.screens.entity.EntityScreen modifier = modifier, playlistName = playlistName, viewState = viewState, - onShuffleButtonClick = onShuffleButtonClick, + onChangeSpeedButtonClick = onChangeSpeedButtonClick, onPlayButtonClick = onPlayButtonClick, onPlayEpisode = latestEpisodeViewModel::onPlayEpisode ) @@ -72,8 +72,8 @@ import com.google.android.horologist.media.ui.screens.entity.EntityScreen fun LatestEpisodeScreen( playlistName: String, viewState: LatestEpisodeViewState, - onShuffleButtonClick: (List) -> Unit, - onPlayButtonClick: (List) -> Unit, + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, modifier: Modifier = Modifier, onPlayEpisode: (PlayerEpisode) -> Unit, ) { @@ -93,14 +93,16 @@ fun LatestEpisodeScreen( downloadItemArtworkPlaceholder = rememberVectorPainter( image = Icons.Default.MusicNote, tintColor = Color.Blue, - ) + ), + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode ) } }, buttonsContent = { ButtonsContent( viewState = viewState, - onShuffleButtonClick = onShuffleButtonClick, + onChangeSpeedButtonClick = onChangeSpeedButtonClick, onPlayButtonClick = onPlayButtonClick, onPlayEpisode = onPlayEpisode ) @@ -112,7 +114,9 @@ fun LatestEpisodeScreen( @Composable fun MediaContent( episode: EpisodeToPodcast, - downloadItemArtworkPlaceholder: Painter? + downloadItemArtworkPlaceholder: Painter?, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit ) { val mediaTitle = episode.episode.title @@ -120,7 +124,10 @@ fun MediaContent( Chip( label = mediaTitle, - onClick = { /*play*/ }, + onClick = { + onPlayButtonClick() + onPlayEpisode(episode.toPlayerEpisode()) + }, secondaryLabel = secondaryLabel, icon = CoilPaintable(episode.podcast.imageUrl, downloadItemArtworkPlaceholder), largeIcon = true, @@ -132,8 +139,8 @@ fun MediaContent( @Composable fun ButtonsContent( viewState: LatestEpisodeViewState, - onShuffleButtonClick: (List) -> Unit, - onPlayButtonClick: (List) -> Unit, + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, onPlayEpisode: (PlayerEpisode) -> Unit ) { @@ -147,7 +154,7 @@ fun ButtonsContent( Button( imageVector = ImageVector.vectorResource(R.drawable.speed), contentDescription = stringResource(id = R.string.speed_button_content_description), - onClick = { onShuffleButtonClick(viewState.libraryEpisodes) }, + onClick = { onChangeSpeedButtonClick() }, modifier = Modifier .weight(weight = 0.3F, fill = false), ) @@ -156,7 +163,7 @@ fun ButtonsContent( imageVector = Icons.Filled.PlayArrow, contentDescription = stringResource(id = R.string.button_play_content_description), onClick = { - onPlayButtonClick(viewState.libraryEpisodes) + onPlayButtonClick() onPlayEpisode(viewState.libraryEpisodes[0].toPlayerEpisode()) }, modifier = Modifier diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt index 328afba234..5987aa609a 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt @@ -18,15 +18,16 @@ package com.example.jetcaster.ui.library import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MusicNote import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -44,14 +45,14 @@ import com.example.jetcaster.R import com.example.jetcaster.core.model.PodcastInfo import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.composables.Section -import com.google.android.horologist.composables.SectionedList import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberColumnState +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState import com.google.android.horologist.compose.material.Button import com.google.android.horologist.compose.material.Chip -import com.google.android.horologist.compose.material.Title +import com.google.android.horologist.images.base.util.rememberVectorPainter import com.google.android.horologist.images.coil.CoilPaintable +import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen @Composable fun PodcastsScreen( @@ -122,87 +123,81 @@ fun PodcastsScreen( @ExperimentalHorologistApi @Composable fun PodcastsScreen( - podcastsScreenState: PodcastsScreenState, + podcastsScreenState: PodcastsScreenState, onPodcastsItemClick: (PodcastInfo) -> Unit, modifier: Modifier = Modifier, - podcastItemArtworkPlaceholder: Painter? = null, ) { - val podcastContent: @Composable (podcast: PodcastInfo) -> Unit = { podcast -> - Chip( - label = podcast.title, - onClick = { onPodcastsItemClick(podcast) }, - icon = CoilPaintable(podcast.imageUrl, podcastItemArtworkPlaceholder), - largeIcon = true, - colors = ChipDefaults.secondaryChipColors(), - ) - } - - PodcastsScreen( - podcastsScreenState = podcastsScreenState, - modifier = modifier, - content = { podcast -> - Chip( - label = podcast.title, - onClick = { onPodcastsItemClick(podcast) }, - icon = CoilPaintable(podcast.imageUrl, podcastItemArtworkPlaceholder), - largeIcon = true, - colors = ChipDefaults.secondaryChipColors(), - ) + val columnState = rememberResponsiveColumnState() + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (podcastsScreenState) { + is PodcastsScreenState.Loaded -> { + EntityScreen( + columnState = columnState, + headerContent = { + DefaultEntityScreenHeader( + title = stringResource( + R.string.podcasts + ) + ) + }, + content = { + items(count = podcastsScreenState.podcastList.size) { + index -> + MediaContent( + podcast = podcastsScreenState.podcastList[index], + downloadItemArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onPodcastsItemClick = onPodcastsItemClick + + ) + } + } + ) + } + PodcastsScreenState.Empty, + PodcastsScreenState.Loading -> { + Column { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } } - ) + } } -@ExperimentalHorologistApi @Composable -fun PodcastsScreen( - podcastsScreenState: PodcastsScreenState, - modifier: Modifier = Modifier, - content: @Composable (podcast: T) -> Unit, +fun MediaContent( + podcast: PodcastInfo, + downloadItemArtworkPlaceholder: Painter?, + onPodcastsItemClick: (PodcastInfo) -> Unit ) { - val columnState = rememberColumnState() - ScreenScaffold(scrollState = columnState) { - SectionedList( - modifier = modifier, - columnState = columnState, - ) { - val sectionState = when (podcastsScreenState) { - is PodcastsScreenState.Loaded -> { - Section.State.Loaded(podcastsScreenState.podcastList) - } - - PodcastsScreenState.Empty -> Section.State.Failed - PodcastsScreenState.Loading -> Section.State.Loading - } - - section(state = sectionState) { - header { - Title( - R.string.podcasts, - Modifier.padding(bottom = 12.dp), - ) - } + val mediaTitle = podcast.title - loaded { content(it) } + val secondaryLabel = podcast.author - loading(count = 4) { - Column { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } - } - } - } - } + Chip( + label = mediaTitle, + onClick = { onPodcastsItemClick(podcast) }, + secondaryLabel = secondaryLabel, + icon = CoilPaintable(podcast.imageUrl, downloadItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) } @ExperimentalHorologistApi -public sealed class PodcastsScreenState { +sealed class PodcastsScreenState { - public object Loading : PodcastsScreenState() + data object Loading : PodcastsScreenState() - public data class Loaded( - val podcastList: List, - ) : PodcastsScreenState() + data class Loaded( + val podcastList: List, + ) : PodcastsScreenState() - public object Empty : PodcastsScreenState() + data object Empty : PodcastsScreenState() } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt index 5e18dc1ebd..1dcca8b9a6 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt @@ -35,7 +35,7 @@ class PodcastsViewModel @Inject constructor( podcastStore: PodcastStore, ) : ViewModel() { - val uiState: StateFlow> = + val uiState: StateFlow = podcastStore.followedPodcastsSortedByLastEpisode(limit = 10).map { if (it.isNotEmpty()) { PodcastsScreenState.Loaded(it.map(PodcastMapper::map)) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt new file mode 100644 index 0000000000..f45f0be1f9 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,211 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material.ChipDefaults +import com.example.jetcaster.R +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.toPlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.model.PodcastInfo +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.images.coil.CoilPaintable +import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable fun PodcastDetailsScreen( + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, + onEpisodeItemClick: (EpisodeToPodcast) -> Unit, + onErrorDialogCancelClick: () -> Unit, + modifier: Modifier = Modifier, + podcastDetailsViewModel: PodcastDetailsViewModel = hiltViewModel() +) { + val uiState by podcastDetailsViewModel.uiState.collectAsStateWithLifecycle() + + PodcastDetailsScreen( + viewState = uiState, + onChangeSpeedButtonClick = onChangeSpeedButtonClick, + onEpisodeItemClick = onEpisodeItemClick, + onPlayEpisode = podcastDetailsViewModel::onPlayEpisode, + onErrorDialogCancelClick = onErrorDialogCancelClick, + onPlayButtonClick = onPlayButtonClick, + modifier = modifier, + ) +} + +@Composable +fun PodcastDetailsScreen( + viewState: PodcastDetailsScreenState, + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, + modifier: Modifier = Modifier, + onEpisodeItemClick: (EpisodeToPodcast) -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit, + onErrorDialogCancelClick: () -> Unit +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (viewState) { + is PodcastDetailsScreenState.Loaded -> { + EntityScreen( + columnState = columnState, + headerContent = { DefaultEntityScreenHeader(title = viewState.podcast.title) }, + buttonsContent = { + ButtonsContent( + episodes = viewState.episodeList, + onChangeSpeedButtonClick = onChangeSpeedButtonClick, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode + ) + }, + content = { + items(count = viewState.episodeList.size) { index -> + MediaContent( + episode = viewState.episodeList[index], + episodeArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onEpisodeItemClick + ) + } + } + ) + } + + PodcastDetailsScreenState.Empty, + PodcastDetailsScreenState.Loading -> { + Column { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + } + } + AlertDialog( + showDialog = viewState == PodcastDetailsScreenState.Empty, + onDismiss = { onErrorDialogCancelClick }, + message = stringResource(R.string.podcasts_no_episode_podcasts) + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun ButtonsContent( + episodes: List, + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit +) { + + Row( + modifier = Modifier + .padding(bottom = 16.dp) + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Button( + imageVector = ImageVector.vectorResource(R.drawable.speed), + contentDescription = stringResource(id = R.string.speed_button_content_description), + onClick = { onChangeSpeedButtonClick() }, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + + Button( + imageVector = Icons.Filled.PlayArrow, + contentDescription = stringResource(id = R.string.button_play_content_description), + onClick = { + onPlayButtonClick() + onPlayEpisode(episodes[0].toPlayerEpisode()) + }, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + } +} + +@Composable +fun MediaContent( + episode: EpisodeToPodcast, + episodeArtworkPlaceholder: Painter?, + onEpisodeItemClick: (EpisodeToPodcast) -> Unit +) { + val mediaTitle = episode.episode.title + + val secondaryLabel = episode.episode.author + + Chip( + label = mediaTitle, + onClick = { onEpisodeItemClick }, + secondaryLabel = secondaryLabel, + icon = CoilPaintable(episode.podcast.imageUrl, episodeArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) +} + +@ExperimentalHorologistApi +sealed class PodcastDetailsScreenState { + + data object Loading : PodcastDetailsScreenState() + + data class Loaded( + val episodeList: List, + val podcast: PodcastInfo, + ) : PodcastDetailsScreenState() + + data object Empty : PodcastDetailsScreenState() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt new file mode 100644 index 0000000000..3de9f6cf6f --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -0,0 +1,102 @@ +/* + * 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.database.model.asExternalModel +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.ui.PodcastDetails +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.flowOf +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 = Uri.decode( + savedStateHandle.get(PodcastDetails.podcastUri) + ) + + private val podcastFlow = if (podcastUri != null) { + podcastStore.podcastWithExtraInfo(podcastUri) + } else { + flowOf(null) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + val uiState: StateFlow = + combine( + podcastFlow, + episodeStore.episodesInPodcast(podcastUri) + ) { podcast, episodeToPodcasts -> + if (podcast != null) { + PodcastDetailsScreenState.Loaded( + podcast = podcast.podcast.asExternalModel() + .copy(isSubscribed = podcast.isFollowed), + episodeList = episodeToPodcasts, + ) + } else { + PodcastDetailsScreenState.Empty + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + PodcastDetailsScreenState.Loading, + ) + + fun onPlayEpisode(episode: PlayerEpisode) { + episodePlayer.currentEpisode = episode + episodePlayer.play() + } +} diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index ec7f044650..2b06b5d941 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Nothing playing No podcasts available at the moment + No episodes available at the moment No title Cancel From 3384c6fe5d635bdc50e02df56a60fe3e5c14ba1b Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 10 Apr 2024 15:17:55 -0700 Subject: [PATCH 134/143] [Jetcaster] Add unit test for mock episode player. --- .../com/example/jetcaster/ui/home/Home.kt | 2 +- .../jetcaster/ui/player/PlayerScreen.kt | 129 ++++++++++++------ .../jetcaster/ui/player/PlayerViewModel.kt | 6 + .../PodcastCategoryFilterUseCaseTest.kt | 2 +- .../core/player/MockEpisodePlayerTest.kt | 104 ++++++++++++++ .../tv/ui/episode/EpisodeScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/player/PlayerScreen.kt | 2 +- 7 files changed, 205 insertions(+), 42 deletions(-) create mode 100644 Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 8c4db45bec..3ed91cdd64 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -454,7 +454,7 @@ private fun HomeContent( LaunchedEffect(pagerState, featuredPodcasts) { snapshotFlow { pagerState.currentPage } .collect { - val podcast = featuredPodcasts.getOrNull(pagerState.currentPage) + val podcast = featuredPodcasts.getOrNull(it) onLibraryPodcastSelected(podcast) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 9008507cfa..242c874545 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -58,11 +58,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider -import androidx.compose.material3.Surface +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -101,6 +105,7 @@ 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 kotlinx.coroutines.launch import java.time.Duration /** @@ -115,10 +120,10 @@ fun PlayerScreen( ) { val uiState = viewModel.uiState PlayerScreen( - uiState, - windowSizeClass, - displayFeatures, - onBackPress, + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, onPlayPress = viewModel::onPlay, onPausePress = viewModel::onPause, onAdvanceBy = viewModel::onAdvanceBy, @@ -126,6 +131,7 @@ fun PlayerScreen( onStop = viewModel::onStop, onNext = viewModel::onNext, onPrevious = viewModel::onPrevious, + onAddToQueue = viewModel::onAddToQueue, ) } @@ -145,6 +151,7 @@ private fun PlayerScreen( onStop: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { DisposableEffect(Unit) { @@ -152,19 +159,35 @@ private fun PlayerScreen( onStop() } } - Surface(modifier) { + + 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, - windowSizeClass, - displayFeatures, - onBackPress, - onPlayPress, - onPausePress, - onAdvanceBy, - onRewindBy, - onNext, - onPrevious, + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + onAddToQueue = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onAddToQueue() + }, + modifier = Modifier.padding(contentPadding) ) } else { FullScreenLoading() @@ -196,6 +219,7 @@ fun PlayerContentWithBackground( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { Box(modifier = modifier, contentAlignment = Alignment.Center) { @@ -204,16 +228,17 @@ fun PlayerContentWithBackground( modifier = Modifier.fillMaxSize() ) PlayerContent( - uiState, - windowSizeClass, - displayFeatures, - onBackPress, - onPlayPress, - onPausePress, - onAdvanceBy, - onRewindBy, - onNext, - onPrevious, + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + onAddToQueue = onAddToQueue ) } } @@ -230,6 +255,7 @@ fun PlayerContent( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() @@ -267,6 +293,7 @@ fun PlayerContent( onRewindBy = onRewindBy, onNext = onNext, onPrevious = onPrevious, + onAddToQueue = onAddToQueue, ) }, strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), @@ -285,7 +312,10 @@ fun PlayerContent( .systemBarsPadding() .padding(horizontal = 8.dp) ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) TwoPane( first = { PlayerContentBookStart(uiState = uiState) @@ -308,15 +338,16 @@ fun PlayerContent( } } else { PlayerContentRegular( - uiState, - onBackPress, - onPlayPress, - onPausePress, + uiState = uiState, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, onAdvanceBy = onAdvanceBy, onRewindBy = onRewindBy, onNext = onNext, onPrevious = onPrevious, - modifier, + onAddToQueue = onAddToQueue, + modifier = modifier, ) } } @@ -334,6 +365,7 @@ private fun PlayerContentRegular( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { val playerEpisode = uiState.episodePlayerState @@ -349,7 +381,10 @@ private fun PlayerContentRegular( .systemBarsPadding() .padding(horizontal = 8.dp) ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 8.dp) @@ -430,6 +465,7 @@ private fun PlayerContentTableTopBottom( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { val episodePlayerState = uiState.episodePlayerState @@ -445,7 +481,10 @@ private fun PlayerContentTableTopBottom( .padding(horizontal = 32.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) PodcastDescription( title = episode.title, podcastName = episode.podcastName, @@ -551,7 +590,10 @@ private fun PlayerContentBookEnd( } @Composable -private fun TopAppBar(onBackPress: () -> Unit) { +private fun TopAppBar( + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, +) { Row(Modifier.fillMaxWidth()) { IconButton(onClick = onBackPress) { Icon( @@ -560,7 +602,7 @@ private fun TopAppBar(onBackPress: () -> Unit) { ) } Spacer(Modifier.weight(1f)) - IconButton(onClick = { /* TODO */ }) { + IconButton(onClick = onAddToQueue) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.cd_add) @@ -575,6 +617,13 @@ private fun TopAppBar(onBackPress: () -> Unit) { } } +@Composable +private fun PlayerCarousel( + modifier: Modifier = Modifier +) { + +} + @Composable private fun PlayerImage( podcastImageUrl: String, @@ -800,7 +849,10 @@ private fun HtmlText( @Composable fun TopAppBarPreview() { JetcasterTheme { - TopAppBar(onBackPress = { }) + TopAppBar( + onBackPress = {}, + onAddToQueue = {}, + ) } } @@ -849,7 +901,8 @@ fun PlayerScreenPreview() { onRewindBy = {}, onStop = {}, onNext = {}, - onPrevious = {} + onPrevious = {}, + onAddToQueue = {}, ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 73804e2177..9e18c86021 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -99,4 +99,10 @@ class PlayerViewModel @Inject constructor( fun onRewindBy(duration: Duration) { episodePlayer.rewindBy(duration) } + + fun onAddToQueue() { + uiState.episodePlayerState.currentEpisode?.let { + episodePlayer.addToQueue(it) + } + } } diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index 2f2d5a3b5b..d0cb16c0aa 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -24,12 +24,12 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.database.model.asExternalModel import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore -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 +import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt new file mode 100644 index 0000000000..1b445bc025 --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -0,0 +1,104 @@ +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.model.PlayerEpisode +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.Test +import java.time.Duration + +@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 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 whenNextQueueEmpty_doesNothing() { + val episode = testEpisodes[0] + mockEpisodePlayer.currentEpisode = episode + mockEpisodePlayer.play() + + mockEpisodePlayer.next() + + assertEquals(episode, mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenAddToQueue_queueNotEmpty() = 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 whenNextQueueNotEmpty_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 whenNextQueueNotEmpty_notRemovedFromQueue() { + } + + @Test + fun whenPreviousQueueEmpty_resetSameEpisode() { + } + + @Test + fun whenPreviousQueueNotEmpty_differentEpisode() { + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index 9974d49952..3b96f178b1 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -26,7 +26,6 @@ import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer 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 @@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index dd3047d44d..355728ac1e 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -78,9 +78,9 @@ 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 +import java.time.Duration @Composable fun PlayerScreen( From bef840d795f1a9757093ccf817a5841f521c95f8 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 09:11:24 -0700 Subject: [PATCH 135/143] Remove unused code. --- .../jetcaster/ui/player/PlayerScreen.kt | 80 +++++++++++-------- .../jetcaster/ui/player/PlayerViewModel.kt | 9 ++- .../core/player/MockEpisodePlayerTest.kt | 30 +++++-- .../designsystem/component/ImageBackground.kt | 2 +- 4 files changed, 80 insertions(+), 41 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 242c874545..c5a00fd48f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -18,6 +18,7 @@ package com.example.jetcaster.ui.player import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -42,6 +43,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -51,8 +53,8 @@ import androidx.compose.material.icons.filled.MoreVert 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.rounded.PauseCircleFilled -import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -202,7 +204,7 @@ private fun PlayerBackground( ) { ImageBackgroundColorScrim( url = episode?.podcastImageUrl, - color = Color.Black.copy(alpha = 0.68f), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), modifier = modifier, ) } @@ -238,7 +240,7 @@ fun PlayerContentWithBackground( onRewindBy = onRewindBy, onNext = onNext, onPrevious = onPrevious, - onAddToQueue = onAddToQueue + onAddToQueue = onAddToQueue, ) } } @@ -281,7 +283,9 @@ fun PlayerContent( if (usingVerticalStrategy) { TwoPane( first = { - PlayerContentTableTopTop(uiState = uiState) + PlayerContentTableTopTop( + uiState = uiState, + ) }, second = { PlayerContentTableTopBottom( @@ -617,13 +621,6 @@ private fun TopAppBar( } } -@Composable -private fun PlayerCarousel( - modifier: Modifier = Modifier -) { - -} - @Composable private fun PlayerImage( podcastImageUrl: String, @@ -654,11 +651,13 @@ private fun PodcastDescription( text = title, style = titleTextStyle, maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.basicMarquee() ) Text( text = podcastName, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1 ) } @@ -742,50 +741,60 @@ private fun PlayerButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { - val buttonsModifier = Modifier + val sideButtonsModifier = Modifier .size(sideButtonSize) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = CircleShape + ) + .semantics { role = Role.Button } + + val primaryButtonModifier = Modifier + .size(playerButtonSize) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape + ) .semantics { role = Role.Button } Image( imageVector = Icons.Filled.SkipPrevious, contentDescription = stringResource(R.string.cd_skip_previous), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + modifier = sideButtonsModifier .clickable(enabled = isPlaying, onClick = onPrevious) ) Image( imageVector = Icons.Filled.Replay10, contentDescription = stringResource(R.string.cd_replay10), - contentScale = ContentScale.Fit, + contentScale = ContentScale.Inside, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + modifier = sideButtonsModifier .clickable { onRewindBy(Duration.ofSeconds(10)) } ) if (isPlaying) { Image( - imageVector = Icons.Rounded.PauseCircleFilled, + imageVector = Icons.Outlined.Pause, contentDescription = stringResource(R.string.cd_pause), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) .clickable { onPausePress() } ) } else { Image( - imageVector = Icons.Rounded.PlayCircleFilled, + imageVector = Icons.Outlined.PlayArrow, contentDescription = stringResource(R.string.cd_play), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) .clickable { onPlayPress() } @@ -794,9 +803,9 @@ private fun PlayerButtons( Image( imageVector = Icons.Filled.Forward10, contentDescription = stringResource(R.string.cd_forward10), - contentScale = ContentScale.Fit, + contentScale = ContentScale.Inside, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + modifier = sideButtonsModifier .clickable { onAdvanceBy(Duration.ofSeconds(10)) } @@ -804,9 +813,9 @@ private fun PlayerButtons( Image( imageVector = Icons.Filled.SkipNext, contentDescription = stringResource(R.string.cd_skip_next), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + modifier = sideButtonsModifier .clickable(enabled = hasNext, onClick = onNext) ) } @@ -890,6 +899,11 @@ fun PlayerScreenPreview() { podcastName = "Podcast", ), isPlaying = false, + queue = listOf( + PlayerEpisode(), + PlayerEpisode(), + PlayerEpisode(), + ) ), ), displayFeatures = emptyList(), diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 9e18c86021..a6d0c99591 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -25,16 +25,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState 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 +import java.time.Duration +import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() @@ -105,4 +106,8 @@ class PlayerViewModel @Inject constructor( episodePlayer.addToQueue(it) } } + + fun onPlay(episode: PlayerEpisode) { + episodePlayer.play(episode) + } } diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index 1b445bc025..53e7d54721 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -91,14 +91,34 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueNotEmpty_notRemovedFromQueue() { - } + fun whenNextQueueNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } - @Test - fun whenPreviousQueueEmpty_resetSameEpisode() { + mockEpisodePlayer.play() + advanceTimeBy(100) + + // TODO override next? + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) } @Test - fun whenPreviousQueueNotEmpty_differentEpisode() { + fun whenPreviousQueueEmpty_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) } } diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt index 83670bf6a5..4cb124dc65 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt @@ -38,7 +38,7 @@ fun ImageBackgroundColorScrim( url = url, modifier = modifier, overlay = { - drawRect(color, blendMode = BlendMode.Multiply) + drawRect(color) } ) } From 23a9f32ed8639f51769b58ec63c02fcf0d652b13 Mon Sep 17 00:00:00 2001 From: arriolac Date: Fri, 12 Apr 2024 20:04:43 +0000 Subject: [PATCH 136/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jetcaster/ui/player/PlayerScreen.kt | 4 ++-- .../jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../domain/PodcastCategoryFilterUseCaseTest.kt | 2 +- .../core/player/MockEpisodePlayerTest.kt | 18 +++++++++++++++++- .../tv/ui/episode/EpisodeScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/player/PlayerScreen.kt | 2 +- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index c5a00fd48f..26904fbe1d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -42,8 +42,8 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -107,8 +107,8 @@ 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 kotlinx.coroutines.launch import java.time.Duration +import kotlinx.coroutines.launch /** * Stateful version of the Podcast player diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index a6d0c99591..edf1cc06b5 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -30,12 +30,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState 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 -import java.time.Duration -import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index d0cb16c0aa..2f2d5a3b5b 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -24,12 +24,12 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.database.model.asExternalModel import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore +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 -import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index 53e7d54721..9bd48e35da 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -1,6 +1,23 @@ +/* + * 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.model.PlayerEpisode +import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -8,7 +25,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test -import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index 3b96f178b1..9974d49952 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -26,6 +26,7 @@ import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer 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 @@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index 355728ac1e..dd3047d44d 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -78,9 +78,9 @@ 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 -import java.time.Duration @Composable fun PlayerScreen( From e369aa8037be99613560c527fd4ea17e287e668f Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 13:20:49 -0700 Subject: [PATCH 137/143] Remove unused method. --- .../java/com/example/jetcaster/ui/player/PlayerViewModel.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index edf1cc06b5..9e18c86021 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -25,7 +25,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore -import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen @@ -106,8 +105,4 @@ class PlayerViewModel @Inject constructor( episodePlayer.addToQueue(it) } } - - fun onPlay(episode: PlayerEpisode) { - episodePlayer.play(episode) - } } From 0d8140a6bfdc5cccfa14dcd7e2c8b4fb31b2c244 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 14:54:12 -0700 Subject: [PATCH 138/143] PR feedback. --- .../jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../core/player/MockEpisodePlayerTest.kt | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 9e18c86021..279d636da1 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -29,12 +29,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState 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 +import java.time.Duration +import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index 9bd48e35da..de3f5d6989 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -17,7 +17,6 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode -import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -25,6 +24,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test +import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest { @@ -47,7 +47,7 @@ class MockEpisodePlayerTest { ) @Test - fun playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + fun whenPlayDone_playerAutoPlaysNextEpisode() = runTest(testDispatcher) { val duration = Duration.ofSeconds(60) val currEpisode = PlayerEpisode( uri = "currentEpisode", @@ -63,7 +63,7 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueEmpty_doesNothing() { + fun whenNext_queueIsEmpty_doesNothing() { val episode = testEpisodes[0] mockEpisodePlayer.currentEpisode = episode mockEpisodePlayer.play() @@ -74,7 +74,7 @@ class MockEpisodePlayerTest { } @Test - fun whenAddToQueue_queueNotEmpty() = runTest(testDispatcher) { + fun whenAddToQueue_queueIsNotEmpty() = runTest(testDispatcher) { testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } advanceUntilIdle() @@ -87,7 +87,7 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueNotEmpty_removeFromQueue() = runTest(testDispatcher) { + fun whenNext_queueIsNotEmpty_removeFromQueue() = runTest(testDispatcher) { mockEpisodePlayer.currentEpisode = PlayerEpisode( uri = "currentEpisode", duration = Duration.ofSeconds(60) @@ -107,7 +107,7 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { + fun whenNext_queueIsNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { mockEpisodePlayer.currentEpisode = PlayerEpisode( uri = "currentEpisode", duration = Duration.ofSeconds(60) @@ -117,7 +117,6 @@ class MockEpisodePlayerTest { mockEpisodePlayer.play() advanceTimeBy(100) - // TODO override next? mockEpisodePlayer.next() advanceTimeBy(100) @@ -128,7 +127,7 @@ class MockEpisodePlayerTest { } @Test - fun whenPreviousQueueEmpty_resetSameEpisode() = runTest(testDispatcher) { + fun whenPrevious_queueIsEmpty_resetSameEpisode() = runTest(testDispatcher) { mockEpisodePlayer.currentEpisode = testEpisodes[0] mockEpisodePlayer.play() advanceTimeBy(1000L) From 7f8062e487143dd6b07120700a43ae2aec4e023f Mon Sep 17 00:00:00 2001 From: arriolac Date: Fri, 12 Apr 2024 21:56:41 +0000 Subject: [PATCH 139/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../example/jetcaster/core/player/MockEpisodePlayerTest.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 279d636da1..9e18c86021 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -29,12 +29,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState 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 -import java.time.Duration -import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index de3f5d6989..7b65b05c40 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -17,6 +17,7 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode +import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -24,7 +25,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test -import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest { From 046a497d360293ccf58276819daa9f75fc10feb6 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Fri, 12 Apr 2024 21:09:33 +0100 Subject: [PATCH 140/143] Adds states for Player, adds queue button --- .../{player => components}/SettingsButtons.kt | 30 +++- .../jetcaster/ui/player/PlayerScreen.kt | 168 +++++++++++------- .../jetcaster/ui/player/PlayerViewModel.kt | 30 +++- .../ui/podcast/PodcastDetailsViewModel.kt | 7 +- .../wear/src/main/res/values/strings.xml | 2 + 5 files changed, 159 insertions(+), 78 deletions(-) rename Jetcaster/wear/src/main/java/com/example/jetcaster/ui/{player => components}/SettingsButtons.kt (65%) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/SettingsButtons.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt similarity index 65% rename from Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/SettingsButtons.kt rename to Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt index 5c1c3ab606..4b98c13f4c 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/SettingsButtons.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt @@ -14,27 +14,33 @@ * limitations under the License. */ -package com.example.jetcaster.ui.player +package com.example.jetcaster.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import com.example.jetcaster.R import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.components.SettingsButtonsDefaults import com.google.android.horologist.audio.ui.components.actions.SetVolumeButton +import com.google.android.horologist.audio.ui.components.actions.SettingsButton +import com.google.android.horologist.compose.material.IconRtlMode /** * Settings buttons for the Jetcaster media app. - * Favorite item and Set Volume. + * Add to queue and Set Volume. */ @Composable fun SettingsButtons( volumeUiState: VolumeUiState, onVolumeClick: () -> Unit, + onAddToQueueClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, ) { @@ -43,7 +49,9 @@ fun SettingsButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { - // FavoriteButton() + AddToQueueButton( + onAddToQueueClick = onAddToQueueClick, + ) SettingsButtonsDefaults.BrandIcon( iconId = R.drawable.ic_logo, @@ -56,3 +64,19 @@ fun SettingsButtons( ) } } + +@Composable +public fun AddToQueueButton( + onAddToQueueClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + SettingsButton( + modifier = modifier, + onClick = onAddToQueueClick, + enabled = enabled, + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + iconRtlMode = IconRtlMode.Mirrored, + contentDescription = stringResource(R.string.add_to_queue_content_description), + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 93d470867a..541dcc864e 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -42,6 +42,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.MaterialTheme import com.example.jetcaster.R +import com.example.jetcaster.core.model.PlayerEpisode +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.rotaryVolumeControlsWithFocus @@ -49,6 +51,7 @@ import com.google.android.horologist.compose.rotaryinput.RotaryDefaults import com.google.android.horologist.media.ui.components.PodcastControlButtons import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground import com.google.android.horologist.media.ui.components.controls.SeekButtonIncrement +import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay import com.google.android.horologist.media.ui.components.display.TextMediaDisplay import com.google.android.horologist.media.ui.screens.player.PlayerScreen @@ -60,93 +63,120 @@ fun PlayerScreen( playerScreenViewModel: PlayerViewModel = hiltViewModel(), ) { val volumeUiState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle() - val playerUiState by playerScreenViewModel.uiState.collectAsStateWithLifecycle() PlayerScreen( - modifier = modifier, - playerUiState = playerUiState, playerScreenViewModel = playerScreenViewModel, volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, + onAddToQueueClick = playerScreenViewModel::addToQueue, + modifier = modifier ) } @Composable private fun PlayerScreen( - playerUiState: PlayerUiState, playerScreenViewModel: PlayerViewModel, volumeUiState: VolumeUiState, onVolumeClick: () -> Unit, + onAddToQueueClick: (PlayerEpisode) -> Unit, onUpdateVolume: (Int) -> Unit, modifier: Modifier = Modifier, ) { - val episode = playerUiState.episodePlayerState.currentEpisode - PlayerScreen( - mediaDisplay = { - if (episode != null && episode.title.isNotEmpty()) { - TextMediaDisplay( - title = episode.podcastName, - subtitle = episode.title - ) - } else { - TextMediaDisplay( - title = stringResource(R.string.nothing_playing), - subtitle = "" - ) - } - }, + val uiState by playerScreenViewModel.uiState.collectAsStateWithLifecycle() - controlButtons = { - if (episode != null && episode.title.isNotEmpty()) { - PodcastControlButtons( - onPlayButtonClick = playerScreenViewModel::onPlay, - onPauseButtonClick = playerScreenViewModel::onPause, - playPauseButtonEnabled = true, - playing = playerUiState.episodePlayerState.isPlaying, - onSeekBackButtonClick = playerScreenViewModel::onRewindBy, - seekBackButtonEnabled = true, - onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, - seekForwardButtonEnabled = true, - seekBackButtonIncrement = SeekButtonIncrement.Ten, - seekForwardButtonIncrement = SeekButtonIncrement.Ten, - trackPositionUiModel = playerUiState.trackPositionUiModel - ) - } else { - 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, - enabled = true, + when (val s = uiState) { + PlayerScreenUiState.Loading -> LoadingMediaDisplay(modifier) + PlayerScreenUiState.Empty -> { + PlayerScreen( + mediaDisplay = { + TextMediaDisplay( + title = stringResource(R.string.nothing_playing), + subtitle = "" + ) + }, + 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, + onAddToQueueClick = {}, + enabled = false, + ) + }, ) - }, - modifier = modifier.rotaryVolumeControlsWithFocus( - volumeUiStateProvider = { volumeUiState }, - onRotaryVolumeInput = onUpdateVolume, - localView = LocalView.current, - isLowRes = RotaryDefaults.isLowResInput(), - ), - background = { - if (episode != null && episode.podcastImageUrl.isNotEmpty()) { - val artworkUri = playerUiState.episodePlayerState.currentEpisode?.podcastImageUrl - ArtworkColorBackground( - artworkUri = artworkUri, - defaultColor = MaterialTheme.colors.primary, - modifier = Modifier.fillMaxSize(), - ) - } } - ) + + is PlayerScreenUiState.Ready -> { + // When screen is ready, episode is always not null, however EpisodePlayerState may + // return a null episode + val episode = s.playerState.episodePlayerState.currentEpisode + + PlayerScreen( + mediaDisplay = { + if (episode != null && episode.title.isNotEmpty()) { + TextMediaDisplay( + title = episode.podcastName, + subtitle = episode.title + ) + } else { + TextMediaDisplay( + title = stringResource(R.string.nothing_playing), + subtitle = "" + ) + } + }, + + controlButtons = { + PodcastControlButtons( + onPlayButtonClick = playerScreenViewModel::onPlay, + onPauseButtonClick = playerScreenViewModel::onPause, + playPauseButtonEnabled = true, + playing = s.playerState.episodePlayerState.isPlaying, + onSeekBackButtonClick = playerScreenViewModel::onRewindBy, + seekBackButtonEnabled = true, + onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, + seekForwardButtonEnabled = true, + seekBackButtonIncrement = SeekButtonIncrement.Ten, + seekForwardButtonIncrement = SeekButtonIncrement.Ten, + trackPositionUiModel = s.playerState.trackPositionUiModel + ) + }, + buttons = { + SettingsButtons( + volumeUiState = volumeUiState, + onVolumeClick = onVolumeClick, + onAddToQueueClick = { + episode?.let { onAddToQueueClick(episode) } + }, + enabled = true, + ) + }, + modifier = modifier.rotaryVolumeControlsWithFocus( + volumeUiStateProvider = { volumeUiState }, + onRotaryVolumeInput = onUpdateVolume, + localView = LocalView.current, + isLowRes = RotaryDefaults.isLowResInput(), + ), + background = { + ArtworkColorBackground( + artworkUri = episode?.let { episode.podcastImageUrl }, + defaultColor = MaterialTheme.colors.primary, + modifier = Modifier.fillMaxSize(), + ) + } + ) + } + } } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 70c4efcb9c..3f0b7e16aa 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -18,8 +18,10 @@ package com.example.jetcaster.ui.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.model.PlayerEpisode 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 @@ -29,7 +31,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -@com.google.android.horologist.annotations.ExperimentalHorologistApi +@OptIn(ExperimentalHorologistApi::class) data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState(), var trackPositionUiModel: TrackPositionUiModel = TrackPositionUiModel.Actual.ZERO @@ -39,13 +41,22 @@ data class PlayerUiState( * 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 { - PlayerUiState(it, buildPositionModel(it)) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PlayerUiState()) + 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) { @@ -78,4 +89,17 @@ class PlayerViewModel @Inject constructor( fun onRewindBy() { episodePlayer.rewindBy(Duration.ofSeconds(10)) } + + fun addToQueue(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } +} + +sealed class PlayerScreenUiState { + data object Loading : PlayerScreenUiState() + data class Ready( + val playerState: PlayerUiState + ) : PlayerScreenUiState() + + data object Empty : PlayerScreenUiState() } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 3de9f6cf6f..d4095913f2 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -61,9 +61,10 @@ class PodcastDetailsViewModel @Inject constructor( podcastStore: PodcastStore ) : ViewModel() { - private val podcastUri: String = Uri.decode( - savedStateHandle.get(PodcastDetails.podcastUri) - ) + private val podcastUri: String = + savedStateHandle.get(PodcastDetails.podcastUri).let { + Uri.decode(it) + } private val podcastFlow = if (podcastUri != null) { podcastStore.podcastWithExtraInfo(podcastUri) diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index 2b06b5d941..d155a7b554 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -65,4 +65,6 @@ No episodes available at the moment No title Cancel + + Add to queue From b0186715c78cebd4834b95897412a8aea69135d1 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Mon, 15 Apr 2024 14:00:39 +0100 Subject: [PATCH 141/143] Adds queue screen --- .../java/com/example/jetcaster/WearApp.kt | 32 +-- .../ui/components/LoadingEntityScreen.kt | 98 +++++++++ .../ui/components/SettingsButtons.kt | 2 +- .../example/jetcaster/ui/home/HomeScreen.kt | 34 ++- .../jetcaster/ui/home/HomeViewModel.kt | 43 ++-- .../jetcaster/ui/library/QueueScreen.kt | 199 ++++++++++++++++++ .../jetcaster/ui/library/QueueViewModel.kt | 67 ++++++ .../ui/podcast/PodcastDetailsScreen.kt | 25 +-- .../wear/src/main/res/values/strings.xml | 4 + 9 files changed, 443 insertions(+), 61 deletions(-) create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/LoadingEntityScreen.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt index 92820f8204..3a0df1e9e9 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -16,22 +16,6 @@ package com.example.jetcaster -/* - * 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 androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -47,10 +31,12 @@ 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.home.HomeScreen import com.example.jetcaster.ui.library.LatestEpisodesScreen import com.example.jetcaster.ui.library.PodcastsScreen +import com.example.jetcaster.ui.library.QueueScreen import com.example.jetcaster.ui.player.PlayerScreen import com.example.jetcaster.ui.podcast.PodcastDetailsScreen import com.google.android.horologist.audio.ui.VolumeViewModel @@ -128,6 +114,20 @@ fun WearApp() { onErrorDialogCancelClick = { navController.popBackStack() } ) } + composable(route = UpNext.navRoute) { + QueueScreen( + // TODO implement change speed + onChangeSpeedButtonClick = {}, + onPlayButtonClick = { + navController.navigateToPlayer() + }, + onEpisodeItemClick = { navController.navigateToPlayer() }, + onErrorDialogCancelClick = { + navController.popBackStack() + navController.navigateToYourPodcast() + } + ) + } }, ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/LoadingEntityScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/LoadingEntityScreen.kt new file mode 100644 index 0000000000..2d3c8980c2 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/LoadingEntityScreen.kt @@ -0,0 +1,98 @@ +/* + * 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.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +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 androidx.compose.ui.unit.dp +import androidx.wear.compose.material.ChipDefaults +import com.example.jetcaster.R +import com.example.jetcaster.ui.library.ButtonsContent +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumnState +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable +fun LoadingEntityScreen(columnState: ScalingLazyColumnState) { + EntityScreen( + columnState = columnState, + headerContent = { + DefaultEntityScreenHeader( + title = stringResource(id = R.string.loading) + ) + }, + buttonsContent = { + ButtonsContent( + onChangeSpeedButtonClick = {}, + onPlayButtonClick = {}, + ) + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun ButtonsContent( + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, + enabled: Boolean = false +) { + + Row( + modifier = Modifier + .padding(bottom = 16.dp) + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Button( + imageVector = ImageVector.vectorResource(R.drawable.speed), + contentDescription = stringResource(id = R.string.speed_button_content_description), + onClick = { onChangeSpeedButtonClick() }, + enabled = enabled, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + + Button( + imageVector = Icons.Filled.PlayArrow, + contentDescription = stringResource(id = R.string.button_play_content_description), + onClick = { onPlayButtonClick }, + enabled = enabled, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt index 4b98c13f4c..4806916d3e 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt @@ -66,7 +66,7 @@ fun SettingsButtons( } @Composable -public fun AddToQueueButton( +fun AddToQueueButton( onAddToQueueClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt index aabff8a686..cb4fd40d45 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -16,6 +16,7 @@ package com.example.jetcaster.ui.home +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MusicNote import androidx.compose.runtime.Composable @@ -27,9 +28,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import com.example.jetcaster.R import com.example.jetcaster.core.model.PodcastInfo @@ -114,12 +119,16 @@ fun HomeScreen( } } item { - Chip( - label = stringResource(R.string.up_next), - onClick = onUpNextClick, - icon = DrawableResPaintable(R.drawable.up_next), - colors = ChipDefaults.secondaryChipColors() - ) + if (viewState.queue.isEmpty()) { + QueueEmpty() + } else { + Chip( + label = stringResource(R.string.up_next), + onClick = onUpNextClick, + icon = DrawableResPaintable(R.drawable.up_next), + colors = ChipDefaults.secondaryChipColors() + ) + } } } } @@ -130,8 +139,7 @@ fun HomeScreen( content = { if (viewState.podcastCategoryFilterResult.topPodcasts.isNotEmpty()) { - val podcast = viewState.podcastCategoryFilterResult.topPodcasts.first() - items(viewState.podcastCategoryFilterResult.topPodcasts.take(1).size) { + items(viewState.podcastCategoryFilterResult.topPodcasts.take(3)) { podcast -> PodcastContent( podcast = podcast, downloadItemArtworkPlaceholder = rememberVectorPainter( @@ -170,3 +178,13 @@ private fun PodcastContent( colors = ChipDefaults.secondaryChipColors(), ) } + +@Composable +private fun QueueEmpty() { + Text( + text = stringResource(id = R.string.add_episode_to_queue), + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index c3538e99f2..5169258265 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.viewModelScope 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.database.model.toPlayerEpisode import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase import com.example.jetcaster.core.data.repository.EpisodeStore @@ -29,6 +28,7 @@ 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.FilterableCategoriesModel +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.model.PodcastCategoryFilterResult import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine @@ -38,7 +38,9 @@ import kotlinx.collections.immutable.toPersistentList 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 @@ -55,9 +57,7 @@ class HomeViewModel @Inject constructor( // Holds our currently selected podcast in the library private val selectedLibraryPodcast = MutableStateFlow(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) + private val selectedHomeCategory = MutableStateFlow(HomeCategory.Library) // Holds our currently selected category private val _selectedCategory = MutableStateFlow(null) @@ -67,7 +67,6 @@ class HomeViewModel @Inject constructor( // Combines the latest value from each of the flows, allowing us to generate a // view state instance which only contains the latest values. val uiState = combine( - homeCategories, selectedHomeCategory, podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), refreshing, @@ -82,27 +81,31 @@ class HomeViewModel @Inject constructor( podcastUri = it?.uri ?: "", limit = 20 ) + }, + episodePlayer.playerState.map { + it.queue } - ) { homeCategories, - homeCategory, - podcasts, - refreshing, - filterableCategories, - podcastCategoryFilterResult, - libraryEpisodes -> + ) { + homeCategory, + podcasts, + refreshing, + filterableCategories, + podcastCategoryFilterResult, + libraryEpisodes, + queue -> _selectedCategory.value = filterableCategories.selectedCategory selectedHomeCategory.value = homeCategory HomeViewState( - homeCategories = homeCategories, selectedHomeCategory = homeCategory, featuredPodcasts = podcasts.toPersistentList(), refreshing = refreshing, filterableCategoriesModel = filterableCategories, podcastCategoryFilterResult = podcastCategoryFilterResult, libraryEpisodes = libraryEpisodes, + queue = queue, errorMessage = null, /* TODO */ ) }.stateIn(viewModelScope, SharingStarted.Lazily, initialValue = HomeViewState()) @@ -130,27 +133,19 @@ class HomeViewModel @Inject constructor( podcastStore.togglePodcastFollowed(podcastUri) } } - - fun onLibraryPodcastSelected(podcast: Podcast?) { - selectedLibraryPodcast.value = podcast - } - - fun onQueuePodcast(episodeToPodcast: EpisodeToPodcast) { - episodePlayer.addToQueue(episodeToPodcast.toPlayerEpisode()) - } } enum class HomeCategory { - Library, Discover + Library, } data class HomeViewState( val featuredPodcasts: List = listOf(), val refreshing: Boolean = false, - val selectedHomeCategory: HomeCategory = HomeCategory.Discover, - val homeCategories: List = emptyList(), + val selectedHomeCategory: HomeCategory = HomeCategory.Library, val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), val libraryEpisodes: List = emptyList(), + val queue: List = emptyList(), val errorMessage: String? = null ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt new file mode 100644 index 0000000000..1eedbba232 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.ChipDefaults +import com.example.jetcaster.R +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.ui.components.LoadingEntityScreen +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.images.coil.CoilPaintable +import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable fun QueueScreen( + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, + onEpisodeItemClick: (EpisodeToPodcast) -> Unit, + onErrorDialogCancelClick: () -> Unit, + modifier: Modifier = Modifier, + queueViewModel: QueueViewModel = hiltViewModel() +) { + val uiState by queueViewModel.uiState.collectAsStateWithLifecycle() + + QueueScreen( + viewState = uiState, + onChangeSpeedButtonClick = onChangeSpeedButtonClick, + onEpisodeItemClick = onEpisodeItemClick, + onErrorDialogCancelClick = onErrorDialogCancelClick, + onPlayButtonClick = onPlayButtonClick, + queueViewModel = queueViewModel, + modifier = modifier, + ) +} + +@Composable +fun QueueScreen( + viewState: QueueScreenState, + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, + modifier: Modifier = Modifier, + onEpisodeItemClick: (EpisodeToPodcast) -> Unit, + queueViewModel: QueueViewModel, + onErrorDialogCancelClick: () -> Unit +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (viewState) { + is QueueScreenState.Loaded -> { + EntityScreen( + columnState = columnState, + headerContent = { + DefaultEntityScreenHeader( + title = stringResource(R.string.queue) + ) + }, + buttonsContent = { + ButtonsContent( + episodes = viewState.episodeList, + onChangeSpeedButtonClick = onChangeSpeedButtonClick, + onPlayButtonClick = onPlayButtonClick, + queueViewModel = queueViewModel + ) + }, + content = { + items(viewState.episodeList) { episode -> + MediaContent( + episode = episode, + episodeArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onEpisodeItemClick + ) + } + } + ) + } + QueueScreenState.Loading -> { + LoadingEntityScreen(columnState) + } + QueueScreenState.Empty -> { + AlertDialog( + showDialog = true, + onDismiss = onErrorDialogCancelClick, + title = stringResource(R.string.display_nothing_in_queue), + message = stringResource(R.string.failed_loading_episodes_from_queue) + ) + } + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun ButtonsContent( + episodes: List, + onChangeSpeedButtonClick: () -> Unit, + onPlayButtonClick: () -> Unit, + queueViewModel: QueueViewModel, +) { + + Row( + modifier = Modifier + .padding(bottom = 16.dp) + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Button( + imageVector = ImageVector.vectorResource(R.drawable.speed), + contentDescription = stringResource(id = R.string.speed_button_content_description), + onClick = { onChangeSpeedButtonClick() }, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + + Button( + imageVector = Icons.Filled.PlayArrow, + contentDescription = stringResource(id = R.string.button_play_content_description), + onClick = { + onPlayButtonClick() + queueViewModel.onPlayEpisode(episodes[0]) + }, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + } +} + +@Composable +fun MediaContent( + episode: PlayerEpisode, + episodeArtworkPlaceholder: Painter?, + onEpisodeItemClick: (EpisodeToPodcast) -> Unit +) { + val mediaTitle = episode.title + + val secondaryLabel = episode.author + + Chip( + label = mediaTitle, + onClick = { onEpisodeItemClick }, + secondaryLabel = secondaryLabel, + icon = CoilPaintable(episode.podcastImageUrl, episodeArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt new file mode 100644 index 0000000000..cf13226f1d --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayer +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 = 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() + } +} + +@ExperimentalHorologistApi +sealed class QueueScreenState { + + data object Loading : QueueScreenState() + + data class Loaded( + val episodeList: List + ) : QueueScreenState() + + data object Empty : QueueScreenState() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index f45f0be1f9..dbbaae4622 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -17,7 +17,6 @@ package com.example.jetcaster.ui.podcast import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -42,8 +41,8 @@ import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.toPlayerEpisode import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.ui.components.LoadingEntityScreen import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.composables.PlaceholderChip import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding import com.google.android.horologist.compose.layout.ScreenScaffold @@ -125,19 +124,18 @@ fun PodcastDetailsScreen( ) } - PodcastDetailsScreenState.Empty, + PodcastDetailsScreenState.Empty -> { + AlertDialog( + showDialog = true, + onDismiss = { onErrorDialogCancelClick }, + message = stringResource(R.string.podcasts_no_episode_podcasts) + ) + } PodcastDetailsScreenState.Loading -> { - Column { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } + LoadingEntityScreen(columnState) } } } - AlertDialog( - showDialog = viewState == PodcastDetailsScreenState.Empty, - onDismiss = { onErrorDialogCancelClick }, - message = stringResource(R.string.podcasts_no_episode_podcasts) - ) } @OptIn(ExperimentalHorologistApi::class) @@ -146,7 +144,8 @@ fun ButtonsContent( episodes: List, onChangeSpeedButtonClick: () -> Unit, onPlayButtonClick: () -> Unit, - onPlayEpisode: (PlayerEpisode) -> Unit + onPlayEpisode: (PlayerEpisode) -> Unit, + enabled: Boolean = true ) { Row( @@ -160,6 +159,7 @@ fun ButtonsContent( imageVector = ImageVector.vectorResource(R.drawable.speed), contentDescription = stringResource(id = R.string.speed_button_content_description), onClick = { onChangeSpeedButtonClick() }, + enabled = enabled, modifier = Modifier .weight(weight = 0.3F, fill = false), ) @@ -171,6 +171,7 @@ fun ButtonsContent( onPlayButtonClick() onPlayEpisode(episodes[0].toPlayerEpisode()) }, + enabled = enabled, modifier = Modifier .weight(weight = 0.3F, fill = false), ) diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index d155a7b554..6fea6274fd 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -62,9 +62,13 @@ Nothing playing No podcasts available at the moment + Loading No episodes available at the moment No title Cancel + No episode in the queue + Add an episode to the queue + Failed at loading episodes from the queue Add to queue From a7bc08a3138d51988425f5f1b346252d1a6d4c79 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 16 Apr 2024 13:42:06 -0700 Subject: [PATCH 142/143] [Jetcaster]: Auto play episode on next. --- .../core/player/MockEpisodePlayer.kt | 6 +++--- .../core/player/MockEpisodePlayerTest.kt | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index a33b308b14..7f9b803cb1 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -17,8 +17,6 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode -import java.time.Duration -import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -31,6 +29,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.time.Duration +import kotlin.reflect.KProperty class MockEpisodePlayer( private val mainDispatcher: CoroutineDispatcher @@ -127,7 +127,6 @@ class MockEpisodePlayer( } next() - play() } override fun pause() { @@ -168,6 +167,7 @@ class MockEpisodePlayer( val nextEpisode = q[0] currentEpisode = nextEpisode queue.value = q - nextEpisode + play() } override fun previous() { diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index 7b65b05c40..b6b6877985 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -17,14 +17,15 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.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 +import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest { @@ -62,6 +63,22 @@ class MockEpisodePlayerTest { 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 whenNext_queueIsEmpty_doesNothing() { val episode = testEpisodes[0] From 3771d3e1e303acaf0734bef75b38498cec5fa49a Mon Sep 17 00:00:00 2001 From: arriolac Date: Tue, 16 Apr 2024 20:45:00 +0000 Subject: [PATCH 143/143] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/jetcaster/core/player/MockEpisodePlayer.kt | 4 ++-- .../example/jetcaster/core/player/MockEpisodePlayerTest.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 7f9b803cb1..25f49baaa4 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -17,6 +17,8 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode +import java.time.Duration +import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -29,8 +31,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.time.Duration -import kotlin.reflect.KProperty class MockEpisodePlayer( private val mainDispatcher: CoroutineDispatcher diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index b6b6877985..f33d90cafd 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -17,6 +17,7 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode +import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -25,7 +26,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest {