diff --git a/Jetcaster/README.md b/Jetcaster/README.md index 364c4165b2..0f8cef1423 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 dynamic theming 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). @@ -11,52 +11,26 @@ 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, and early stages of dynamic theming. - - ## 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 screen, the podcast details screen, and the player screen The home screen is split into sub-screens for easy re-use: -- __Home__, allowing the user to see their 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). -The player screen layout is adapting to different form factors, including a tabletop layout on foldable devices: - - - -### Dynamic theming -The home screen currently implements dynamic theming, using the artwork of the currently selected podcast from the carousel to update the `primary` and `onPrimary` [colors](https://developer.android.com/reference/kotlin/androidx/compose/material/Colors). You can see it in action in the screenshots above: as the carousel item is changed, the background gradient is updated to match the artwork. - -This is implemented in [`DynamicTheming.kt`](app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt), which provides the `DynamicThemePrimaryColorsFromImage` composable, to automatically animate the theme colors based on the provided image URL, like so: +Multiple panes will also be shown depending on the device's [window size class][wsc]. -``` kotlin -val dominantColorState: DominantColorState = rememberDominantColorState() - -DynamicThemePrimaryColorsFromImage(dominantColorState) { - var imageUrl = remember { mutableStateOf("") } - - // When the image url changes, call updateColorsFromImageUrl() - launchInComposition(imageUrl) { - dominantColorState.updateColorsFromImageUrl(imageUrl) - } - - // Content which will be dynamically themed.... -} -``` +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: -Underneath, [`DominantColorState`](app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt) uses the [Coil][coil] library to fetch the artwork image 🖼️, and then [Palette][palette] to extract the dominant colors from the image 🎨. +![readme_fold](https://github.com/android/compose-samples/assets/10263978/fe02248f-81ce-489b-a6d6-838438c8368e) ### Others @@ -139,3 +113,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 diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index 0344801aa8..5d6a17a8ee 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 { @@ -84,6 +85,7 @@ android { } dependencies { + implementation(project(":core:model")) val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) @@ -95,14 +97,20 @@ 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.material) - implementation(libs.androidx.compose.materialWindow) implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.adaptive.layout) + implementation(libs.androidx.compose.material3.adaptive.navigation) + implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) @@ -112,20 +120,14 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.window) + implementation(libs.androidx.window.core) implementation(libs.accompanist.adaptive) implementation(libs.coil.kt.compose) - implementation(libs.okhttp3) - implementation(libs.okhttp.logging) - - implementation(libs.rometools.rome) - implementation(libs.rometools.modules) - - implementation(libs.androidx.room.runtime) - implementation(libs.androidx.room.ktx) + implementation(project(":core")) + implementation(project(":designsystem")) - ksp(libs.androidx.room.compiler) coreLibraryDesugaring(libs.core.jdk.desugaring) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt deleted file mode 100644 index 3d831558a7..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt +++ /dev/null @@ -1,114 +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 - -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.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. - * - * 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 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 { - PodcastStore( - podcastDao = database.podcastsDao(), - podcastFollowedEntryDao = database.podcastFollowedEntryDao(), - transactionRunner = transactionRunner - ) - } - - val episodeStore by lazy { - EpisodeStore( - episodesDao = database.episodesDao() - ) - } - - val categoryStore by lazy { - CategoryStore( - 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 = 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() - } -} 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..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,20 +19,16 @@ package com.example.jetcaster import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject /** * Application which sets up our dependency [Graph] with a context. */ +@HiltAndroidApp class JetcasterApplication : Application(), ImageLoaderFactory { - 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/data/EpisodeStore.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt deleted file mode 100644 index d60fa6e7c4..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt +++ /dev/null @@ -1,54 +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.data - -import com.example.jetcaster.data.room.EpisodesDao -import kotlinx.coroutines.flow.Flow - -/** - * A data repository for [Episode] instances. - */ -class EpisodeStore( - private val episodesDao: EpisodesDao -) { - /** - * Returns a flow containing the episode given [episodeUri]. - */ - fun episodeWithUri(episodeUri: String): Flow { - return episodesDao.episode(episodeUri) - } - - /** - * Returns a flow containing the list of episodes associated with the podcast with the - * given [podcastUri]. - */ - fun episodesInPodcast( - podcastUri: String, - limit: Int = Integer.MAX_VALUE - ): Flow> { - return episodesDao.episodesForPodcastUri(podcastUri, limit) - } - - /** - * Add a new [Episode] to this store. - * - * This automatically switches to the main thread to maintain thread consistency. - */ - suspend fun addEpisodes(episodes: Collection) = episodesDao.insertAll(episodes) - - suspend fun isEmpty(): Boolean = episodesDao.count() == 0 -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt deleted file mode 100644 index b9ace6b52e..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt +++ /dev/null @@ -1,85 +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.data - -import com.example.jetcaster.data.room.PodcastFollowedEntryDao -import com.example.jetcaster.data.room.PodcastsDao -import com.example.jetcaster.data.room.TransactionRunner -import kotlinx.coroutines.flow.Flow - -/** - * A data repository for [Podcast] instances. - */ -class PodcastStore( - private val podcastDao: PodcastsDao, - private val podcastFollowedEntryDao: PodcastFollowedEntryDao, - private val transactionRunner: TransactionRunner -) { - /** - * Return a flow containing the [Podcast] with the given [uri]. - */ - fun podcastWithUri(uri: String): Flow { - return podcastDao.podcastWithUri(uri) - } - - /** - * Returns a flow containing the entire collection of podcasts, sorted by the last episode - * publish date for each podcast. - */ - fun podcastsSortedByLastEpisode( - limit: Int = Int.MAX_VALUE - ): Flow> { - return podcastDao.podcastsSortedByLastEpisode(limit) - } - - /** - * Returns a flow containing a list of all followed podcasts, sorted by the their last - * episode date. - */ - fun followedPodcastsSortedByLastEpisode( - limit: Int = Int.MAX_VALUE - ): Flow> { - return podcastDao.followedPodcastsSortedByLastEpisode(limit) - } - - private suspend fun followPodcast(podcastUri: String) { - podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri)) - } - - suspend fun togglePodcastFollowed(podcastUri: String) = transactionRunner { - if (podcastFollowedEntryDao.isPodcastFollowed(podcastUri)) { - unfollowPodcast(podcastUri) - } else { - followPodcast(podcastUri) - } - } - - suspend fun unfollowPodcast(podcastUri: String) { - podcastFollowedEntryDao.deleteWithPodcastUri(podcastUri) - } - - /** - * Add a new [Podcast] to this store. - * - * This automatically switches to the main thread to maintain thread consistency. - */ - suspend fun addPodcast(podcast: Podcast) { - podcastDao.insert(podcast) - } - - suspend fun isEmpty(): Boolean = podcastDao.count() == 0 -} 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 4a49efdf09..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 @@ -16,50 +16,44 @@ package com.example.jetcaster.ui -import androidx.compose.material.AlertDialog -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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.ui.home.Home +import com.example.jetcaster.ui.home.MainScreen import com.example.jetcaster.ui.player.PlayerScreen -import com.example.jetcaster.ui.player.PlayerViewModel +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun JetcasterApp( - windowSizeClass: WindowSizeClass, displayFeatures: List, appState: JetcasterAppState = rememberJetcasterAppState() ) { + val adaptiveInfo = currentWindowAdaptiveInfo() if (appState.isOnline) { NavHost( navController = appState.navController, startDestination = Screen.Home.route ) { composable(Screen.Home.route) { backStackEntry -> - Home( - navigateToPlayer = { episodeUri -> - appState.navigateToPlayer(episodeUri, backStackEntry) + MainScreen( + windowSizeClass = adaptiveInfo.windowSizeClass, + navigateToPlayer = { episode -> + appState.navigateToPlayer(episode.uri, backStackEntry) } ) } - composable(Screen.Player.route) { backStackEntry -> - val playerViewModel: PlayerViewModel = viewModel( - factory = PlayerViewModel.provideFactory( - owner = backStackEntry, - defaultArgs = backStackEntry.arguments - ) - ) + composable(Screen.Player.route) { PlayerScreen( - playerViewModel, - windowSizeClass, - displayFeatures, + windowSizeClass = adaptiveInfo.windowSizeClass, + displayFeatures = displayFeatures, onBackPress = appState::navigateBack ) } 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/MainActivity.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt index 3c18739094..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 @@ -16,34 +16,26 @@ package com.example.jetcaster.ui -import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle 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( - // This app is only ever in dark mode, so hard code detectDarkMode to true. - SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT, detectDarkMode = { true }) - ) + 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 937b18e0ce..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 @@ -14,277 +14,531 @@ * limitations under the License. */ +@file:OptIn(ExperimentalFoundationApi::class) + 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.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio 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.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars +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.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 import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Tab -import androidx.compose.material.TabPosition -import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults.tabIndicatorOffset -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar 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.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 +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.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.layout.ThreePaneScaffoldValue +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.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.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.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel +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.data.Category -import com.example.jetcaster.data.EpisodeToPodcast -import com.example.jetcaster.data.PodcastWithExtraInfo -import com.example.jetcaster.ui.home.category.PodcastCategoryViewState -import com.example.jetcaster.ui.home.discover.DiscoverViewState +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 +import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel import com.example.jetcaster.ui.theme.JetcasterTheme -import com.example.jetcaster.ui.theme.Keyline1 -import com.example.jetcaster.ui.theme.MinContrastOfPrimaryVsSurface -import com.example.jetcaster.util.DynamicThemePrimaryColorsFromImage import com.example.jetcaster.util.ToggleFollowPodcastIconButton -import com.example.jetcaster.util.contrastAgainst +import com.example.jetcaster.util.fullWidthItem +import com.example.jetcaster.util.isCompact import com.example.jetcaster.util.quantityStringResource -import com.example.jetcaster.util.rememberDominantColorState -import com.example.jetcaster.util.verticalGradientScrim +import com.example.jetcaster.util.radialGradientScrim import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch + +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, +) + +private val HomeState.showHomeCategoryTabs: Boolean + get() = featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty() + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun HomeState.showGrid( + scaffoldValue: ThreePaneScaffoldValue +): Boolean = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED || + ( + windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM && + scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden + ) + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +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, + verticalHingePolicy: HingePolicy = HingePolicy.AvoidSeparating +): PaneScaffoldDirective { + val maxHorizontalPartitions: Int + val verticalSpacerSize: Dp + if (windowAdaptiveInfo.windowSizeClass.isCompact) { + // Window width or height is compact. Limit to 1 pane horizontally. + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } else { + when (windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> { + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } + WindowWidthSizeClass.MEDIUM -> { + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } + else -> { + maxHorizontalPartitions = 2 + verticalSpacerSize = 24.dp + } + } + } + val maxVerticalPartitions: Int + val horizontalSpacerSize: Dp + + if (windowAdaptiveInfo.windowPosture.isTabletop) { + maxVerticalPartitions = 2 + horizontalSpacerSize = 24.dp + } else { + maxVerticalPartitions = 1 + horizontalSpacerSize = 0.dp + } + + val defaultPanePreferredWidth = 360.dp + return PaneScaffoldDirective( + maxHorizontalPartitions, + verticalSpacerSize, + maxVerticalPartitions, + horizontalSpacerSize, + defaultPanePreferredWidth, + getExcludedVerticalBounds(windowAdaptiveInfo.windowPosture, verticalHingePolicy) + ) +} + +/** + * Copied from `getExcludedVerticalBounds()` in [PaneScaffoldDirective] since it is private. + */ +@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() + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun Home( - navigateToPlayer: (String) -> Unit, - viewModel: HomeViewModel = viewModel() +fun MainScreen( + windowSizeClass: WindowSizeClass, + navigateToPlayer: (EpisodeInfo) -> Unit, + viewModel: HomeViewModel = hiltViewModel() ) { val viewState by viewModel.state.collectAsStateWithLifecycle() - Surface(Modifier.fillMaxSize()) { - Home( - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - discoverViewState = viewState.discoverViewState, - podcastCategoryViewState = viewState.podcastCategoryViewState, - libraryEpisodes = viewState.libraryEpisodes, - onHomeCategorySelected = viewModel::onHomeCategorySelected, - onCategorySelected = viewModel::onCategorySelected, - onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, - modifier = Modifier.fillMaxSize() - ) + val navigator = rememberSupportingPaneScaffoldNavigator( + scaffoldDirective = calculateScaffoldDirective(currentWindowAdaptiveInfo()) + ) + 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 { + val podcastUri = navigator.currentDestination?.content + val showGrid = homeState.showGrid(navigator.scaffoldValue) + if (podcastUri.isNullOrEmpty()) { + HomeScreen( + homeState = homeState, + showGrid = showGrid, + modifier = Modifier.fillMaxSize() + ) + } else { + SupportingPaneScaffold( + value = navigator.scaffoldValue, + directive = navigator.scaffoldDirective, + supportingPane = { + val podcastDetailsViewModel = + hiltViewModel( + key = podcastUri + ) { + it.create(podcastUri) + } + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = navigateToPlayer, + navigateBack = { + if (navigator.canNavigateBack()) { + navigator.navigateBack() + } + }, + showBackButton = navigator.isMainPaneHidden(), + ) + }, + mainPane = { + HomeScreen( + homeState = homeState, + showGrid = showGrid, + modifier = Modifier.fillMaxSize() + ) + }, + modifier = Modifier.fillMaxSize() + ) + } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeAppBar( - backgroundColor: Color, - modifier: Modifier = Modifier +private fun HomeAppBar( + isExpanded: Boolean, + modifier: Modifier = Modifier, ) { - TopAppBar( - title = { - Row { - Image( - painter = painterResource(R.drawable.ic_logo), + 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( - painter = painterResource(R.drawable.ic_text_logo), - contentDescription = stringResource(R.string.app_name), - modifier = Modifier - .padding(start = 4.dp) - .heightIn(max = 24.dp) + imageVector = Icons.Default.AccountCircle, + contentDescription = stringResource(R.string.cd_account) ) - } - }, - backgroundColor = backgroundColor, - actions = { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - IconButton( - onClick = { /* TODO: Open search */ } - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.cd_search) - ) - } - IconButton( - onClick = { /* TODO: Open account? */ } - ) { - Icon( - imageVector = Icons.Default.AccountCircle, - contentDescription = stringResource(R.string.cd_account) - ) - } - } - }, - modifier = modifier - ) + }, + modifier = if (isExpanded) Modifier else Modifier.fillMaxWidth() + ) { } + } } -@OptIn(ExperimentalFoundationApi::class) @Composable -fun Home( - featuredPodcasts: PersistentList, - isRefreshing: Boolean, - selectedHomeCategory: HomeCategory, - homeCategories: List, - discoverViewState: DiscoverViewState, - podcastCategoryViewState: PodcastCategoryViewState, - libraryEpisodes: List, +private fun HomeScreenBackground( modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, - onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + content: @Composable BoxScope.() -> Unit ) { - Column( - modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars) + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.background) ) { - // We dynamically theme this sub-section of the layout to match the selected - // 'top podcast' - - val surfaceColor = MaterialTheme.colors.surface - val appBarColor = surfaceColor.copy(alpha = 0.87f) - val dominantColorState = rememberDominantColorState { color -> - // We want a color which has sufficient contrast against the surface color - color.contrastAgainst(surfaceColor) >= MinContrastOfPrimaryVsSurface - } - - DynamicThemePrimaryColorsFromImage(dominantColorState) { - val pagerState = rememberPagerState { featuredPodcasts.size } - - val selectedImageUrl = featuredPodcasts.getOrNull(pagerState.currentPage) - ?.podcast?.imageUrl - - // When the selected image url changes, call updateColorsFromImageUrl() or reset() - LaunchedEffect(selectedImageUrl) { - if (selectedImageUrl != null) { - dominantColorState.updateColorsFromImageUrl(selectedImageUrl) - } else { - dominantColorState.reset() - } - } + Box( + modifier = Modifier + .fillMaxSize() + .radialGradientScrim(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)) + ) + content() + } +} - val scrimColor = MaterialTheme.colors.primary.copy(alpha = 0.38f) +@Composable +private fun HomeScreen( + homeState: HomeState, + showGrid: Boolean, + modifier: Modifier = Modifier +) { + // Effect that changes the home category selection when there are no subscribed podcasts + LaunchedEffect(key1 = homeState.featuredPodcasts) { + if (homeState.featuredPodcasts.isEmpty()) { + homeState.onHomeCategorySelected(HomeCategory.Discover) + } + } - // Top Bar - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = scrimColor) - ) { - // Draw a scrim over the status bar which matches the app bar - Spacer( - Modifier - .background(appBarColor) - .fillMaxWidth() - .windowInsetsTopHeight(WindowInsets.statusBars) - ) + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + HomeScreenBackground( + modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars) + ) { + Scaffold( + topBar = { HomeAppBar( - backgroundColor = appBarColor, - modifier = Modifier.fillMaxWidth() + 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( - featuredPodcasts = featuredPodcasts, - isRefreshing = isRefreshing, - selectedHomeCategory = selectedHomeCategory, - homeCategories = homeCategories, - discoverViewState = discoverViewState, - podcastCategoryViewState = podcastCategoryViewState, - libraryEpisodes = libraryEpisodes, - scrimColor = scrimColor, - pagerState = pagerState, - onPodcastUnfollowed = onPodcastUnfollowed, - onHomeCategorySelected = onHomeCategorySelected, - onCategorySelected = onCategorySelected, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed + 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) + } ) } } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeContent( - featuredPodcasts: PersistentList, + showGrid: Boolean, + showHomeCategoryTabs: Boolean, + featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, homeCategories: List, - discoverViewState: DiscoverViewState, - podcastCategoryViewState: PodcastCategoryViewState, - libraryEpisodes: List, - scrimColor: Color, + 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, +) { + val pagerState = rememberPagerState { featuredPodcasts.size } + LaunchedEffect(pagerState, featuredPodcasts) { + snapshotFlow { pagerState.currentPage } + .collect { + val podcast = featuredPodcasts.getOrNull(it) + 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, + 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, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, + ) + } else { + HomeContentColumn( + 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, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HomeContentColumn( + showHomeCategoryTabs: Boolean, pagerState: PagerState, + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + homeCategories: List, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, + onPodcastUnfollowed: (PodcastInfo) -> Unit, onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, ) { - LazyColumn(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { if (featuredPodcasts.isNotEmpty()) { item { FollowedPodcastItem( - items = featuredPodcasts, pagerState = pagerState, + items = featuredPodcasts, onPodcastUnfollowed = onPodcastUnfollowed, + navigateToPodcastDetails = navigateToPodcastDetails, modifier = Modifier .fillMaxWidth() - .verticalGradientScrim( - color = scrimColor, - startYPercentage = 1f, - endYPercentage = 0f - ) ) } } @@ -293,11 +547,12 @@ private fun HomeContent( // TODO show a progress indicator or similar } - if (homeCategories.isNotEmpty()) { - stickyHeader { + if (showHomeCategoryTabs) { + item { HomeCategoryTabs( categories = homeCategories, selectedCategory = selectedHomeCategory, + showHorizontalLine = true, onCategorySelected = onHomeCategorySelected ) } @@ -306,43 +561,123 @@ private fun HomeContent( when (selectedHomeCategory) { HomeCategory.Library -> { libraryItems( - episodes = libraryEpisodes, - navigateToPlayer = navigateToPlayer + library = library, + navigateToPlayer = navigateToPlayer, + onQueueEpisode = onQueueEpisode ) } HomeCategory.Discover -> { discoverItems( - discoverViewState = discoverViewState, - podcastCategoryViewState = podcastCategoryViewState, + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, navigateToPlayer = navigateToPlayer, onCategorySelected = onCategorySelected, - onTogglePodcastFollowed = onTogglePodcastFollowed + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode + ) + } + } + } +} + +@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, + 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 + } + + if (showHomeCategoryTabs) { + fullWidthItem { + Row { + HomeCategoryTabs( + categories = homeCategories, + selectedCategory = selectedHomeCategory, + showHorizontalLine = false, + onCategorySelected = onHomeCategorySelected, + modifier = Modifier.width(240.dp) + ) + } + } + } + + 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 ) } } } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun FollowedPodcastItem( - items: PersistentList, pagerState: PagerState, - onPodcastUnfollowed: (String) -> Unit, + items: PersistentList, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Spacer(Modifier.height(16.dp)) FollowedPodcasts( - items = items, pagerState = pagerState, + items = items, onPodcastUnfollowed = onPodcastUnfollowed, - modifier = Modifier - .padding(start = Keyline1, top = 16.dp, end = Keyline1) - .fillMaxWidth() - .height(200.dp) + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(16.dp)) @@ -354,8 +689,13 @@ private fun HomeCategoryTabs( categories: List, selectedCategory: HomeCategory, onCategorySelected: (HomeCategory) -> Unit, - modifier: Modifier = Modifier + showHorizontalLine: Boolean, + modifier: Modifier = Modifier, ) { + if (categories.isEmpty()) { + return + } + val selectedIndex = categories.indexOfFirst { it == selectedCategory } val indicator = @Composable { tabPositions: List -> HomeCategoryTabIndicator( @@ -365,8 +705,14 @@ private fun HomeCategoryTabs( TabRow( selectedTabIndex = selectedIndex, + containerColor = Color.Transparent, indicator = indicator, - modifier = modifier + modifier = modifier, + divider = { + if (showHorizontalLine) { + HorizontalDivider() + } + } ) { categories.forEachIndexed { index, category -> Tab( @@ -378,7 +724,7 @@ private fun HomeCategoryTabs( HomeCategory.Library -> stringResource(R.string.home_library) HomeCategory.Discover -> stringResource(R.string.home_discover) }, - style = MaterialTheme.typography.body2 + style = MaterialTheme.typography.bodyMedium ) } ) @@ -387,9 +733,9 @@ private fun HomeCategoryTabs( } @Composable -fun HomeCategoryTabIndicator( +private fun HomeCategoryTabIndicator( modifier: Modifier = Modifier, - color: Color = MaterialTheme.colors.onSurface + color: Color = MaterialTheme.colorScheme.onSurface ) { Spacer( modifier @@ -399,28 +745,47 @@ fun HomeCategoryTabIndicator( ) } -@OptIn(ExperimentalFoundationApi::class) +private val FEATURED_PODCAST_IMAGE_SIZE_DP = 160.dp + @Composable -fun FollowedPodcasts( - items: PersistentList, +private fun FollowedPodcasts( pagerState: PagerState, + items: PersistentList, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, ) { - HorizontalPager( - state = pagerState, - modifier = modifier - ) { page -> - val (podcast, lastEpisodeDate) = items[page] - FollowedPodcastCarouselItem( - podcastImageUrl = podcast.imageUrl, - podcastTitle = podcast.title, - onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, - lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, - modifier = Modifier - .padding(4.dp) - .fillMaxSize() - ) + // TODO: Using BoxWithConstraints is not quite performant since it requires 2 passes to compute + // the content padding. This should be revisited once a carousel component is available. + // Alternatively, version 1.7.0-alpha05 of Compose Foundation supports `snapPosition` + // which solves this problem and avoids this calculation altogether. Once 1.7.0 is + // stable, this implementation can be updated. + BoxWithConstraints( + modifier = modifier.background(Color.Transparent) + ) { + val horizontalPadding = (this.maxWidth - FEATURED_PODCAST_IMAGE_SIZE_DP) / 2 + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = 16.dp, + ), + pageSpacing = 24.dp, + pageSize = PageSize.Fixed(FEATURED_PODCAST_IMAGE_SIZE_DP) + ) { page -> + val podcast = items[page] + FollowedPodcastCarouselItem( + podcastImageUrl = podcast.imageUrl, + podcastTitle = podcast.title, + onUnfollowedClick = { onPodcastUnfollowed(podcast) }, + lastEpisodeDateText = podcast.lastEpisodeDate?.let { lastUpdated(it) }, + modifier = Modifier + .fillMaxSize() + .clickable { + navigateToPodcastDetails(podcast) + } + ) + } } } @@ -432,14 +797,11 @@ private fun FollowedPodcastCarouselItem( lastEpisodeDateText: String? = null, onUnfollowedClick: () -> Unit, ) { - Column( - modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) { + Column(modifier) { Box( Modifier - .weight(1f) + .size(FEATURED_PODCAST_IMAGE_SIZE_DP) .align(Alignment.CenterHorizontally) - .aspectRatio(1f) ) { if (podcastImageUrl != null) { AsyncImage( @@ -460,17 +822,15 @@ private fun FollowedPodcastCarouselItem( } if (lastEpisodeDateText != null) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = lastEpisodeDateText, - style = MaterialTheme.typography.caption, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 8.dp) - .align(Alignment.CenterHorizontally) - ) - } + Text( + text = lastEpisodeDateText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally) + ) } } } @@ -492,36 +852,94 @@ private fun lastUpdated(updated: OffsetDateTime): String { } } -@Composable +@OptIn(ExperimentalMaterial3Api::class) @Preview -fun PreviewHomeContent() { +@Composable +private fun HomeAppBarPreview() { + JetcasterTheme { + HomeAppBar( + isExpanded = false, + ) + } +} + +private val CompactWindowSizeClass = WindowSizeClass.compute(360f, 780f) + +@Preview(device = Devices.PHONE) +@Composable +private fun PreviewHomeContent() { + JetcasterTheme { + val homeState = HomeState( + 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 = {} + ) + HomeScreen( + homeState = homeState, + showGrid = false + ) + } +} + +@Preview(device = Devices.FOLDABLE) +@Preview(device = Devices.TABLET) +@Preview(device = Devices.DESKTOP) +@Composable +private fun PreviewHomeContentExpanded() { JetcasterTheme { - Home( - featuredPodcasts = PreviewPodcastsWithExtraInfo, + val homeState = HomeState( + windowSizeClass = CompactWindowSizeClass, + featuredPodcasts = PreviewPodcasts.toPersistentList(), isRefreshing = false, homeCategories = HomeCategory.entries, selectedHomeCategory = HomeCategory.Discover, - discoverViewState = DiscoverViewState( + filterableCategoriesModel = FilterableCategoriesModel( categories = PreviewCategories, - selectedCategory = PreviewCategories.first(), + selectedCategory = PreviewCategories.firstOrNull() ), - podcastCategoryViewState = PodcastCategoryViewState( - topPodcasts = PreviewPodcastsWithExtraInfo, - episodes = PreviewEpisodeToPodcasts, + podcastCategoryFilterResult = PodcastCategoryFilterResult( + topPodcasts = PreviewPodcasts, + episodes = PreviewPodcastCategoryEpisodes ), - libraryEpisodes = emptyList(), + library = LibraryInfo(), onCategorySelected = {}, onPodcastUnfollowed = {}, + navigateToPodcastDetails = {}, navigateToPlayer = {}, onHomeCategorySelected = {}, - onTogglePodcastFollowed = {} + onTogglePodcastFollowed = {}, + onLibraryPodcastSelected = {}, + onQueueEpisode = {} + ) + HomeScreen( + homeState = homeState, + showGrid = true ) } } @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/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 9240c54076..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 @@ -18,17 +18,23 @@ 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.ui.home.category.PodcastCategoryViewState -import com.example.jetcaster.ui.home.discover.DiscoverViewState -import com.example.jetcaster.util.combine +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.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 +import javax.inject.Inject import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -36,87 +42,32 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -class HomeViewModel( - private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, - private val categoryStore: CategoryStore = Graph.categoryStore, - private val podcastStore: PodcastStore = Graph.podcastStore, - private val episodeStore: EpisodeStore = Graph.episodeStore +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class HomeViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val podcastStore: PodcastStore, + private val episodeStore: EpisodeStore, + private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase, + private val filterableCategoriesUseCase: FilterableCategoriesUseCase, + private val episodePlayer: EpisodePlayer, ) : ViewModel() { + // Holds our currently selected podcast in the library + private val selectedLibraryPodcast = MutableStateFlow(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 private val refreshing = MutableStateFlow(false) - @OptIn(ExperimentalCoroutinesApi::class) - private val libraryEpisodes = - podcastStore.followedPodcastsSortedByLastEpisode() - .flatMapLatest { followedPodcasts -> - if (followedPodcasts.isEmpty()) { - flowOf(emptyList()) - } else { - combine( - followedPodcasts.map { p -> - episodeStore.episodesInPodcast(p.podcast.uri, 5) - } - ) { allEpisodes -> - allEpisodes.toList().flatten().sortedByDescending { it.episode.published } - } - } - } - - private val discover = combine( - categoryStore.categoriesSortedByPodcastCount() - .onEach { categories -> - // If we haven't got a selected category yet, select the first - if (categories.isNotEmpty() && _selectedCategory.value == null) { - _selectedCategory.value = categories[0] - } - }, - _selectedCategory - ) { categories, selectedCategory -> - DiscoverViewState( - categories = categories, - selectedCategory = selectedCategory - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val podcastCategory = _selectedCategory.flatMapLatest { category -> - if (category == null) { - return@flatMapLatest flowOf(PodcastCategoryViewState()) - } - - val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount( - category.id, - limit = 10 - ) - - val episodesFlow = categoryStore.episodesFromPodcastsInCategory( - category.id, - limit = 20 - ) - - // Combine our flows and collect them into the view state StateFlow - combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes -> - PodcastCategoryViewState( - topPodcasts = topPodcasts, - episodes = episodes - ) - } - } - val state: StateFlow get() = _state @@ -127,26 +78,43 @@ class HomeViewModel( combine( homeCategories, selectedHomeCategory, - podcastStore.followedPodcastsSortedByLastEpisode(limit = 20), + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), refreshing, - discover, - podcastCategory, - libraryEpisodes + _selectedCategory.flatMapLatest { selectedCategory -> + filterableCategoriesUseCase(selectedCategory) + }, + _selectedCategory.flatMapLatest { + podcastCategoryFilterUseCase(it) + }, + selectedLibraryPodcast.flatMapLatest { + episodeStore.episodesInPodcast( + podcastUri = it?.uri ?: "", + limit = 20 + ) + } ) { homeCategories, - selectedHomeCategory, + homeCategory, podcasts, refreshing, - discoverViewState, - podcastCategoryViewState, + filterableCategories, + podcastCategoryFilterResult, libraryEpisodes -> + + _selectedCategory.value = filterableCategories.selectedCategory + + // Override selected home category to show 'DISCOVER' if there are no + // featured podcasts + selectedHomeCategory.value = + if (podcasts.isEmpty()) HomeCategory.Discover else homeCategory + HomeViewState( homeCategories = homeCategories, - selectedHomeCategory = selectedHomeCategory, - featuredPodcasts = podcasts.toPersistentList(), + selectedHomeCategory = homeCategory, + featuredPodcasts = podcasts.map { it.asExternalModel() }.toPersistentList(), refreshing = refreshing, - discoverViewState = discoverViewState, - podcastCategoryViewState = podcastCategoryViewState, - libraryEpisodes = libraryEpisodes, + filterableCategoriesModel = filterableCategories, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = libraryEpisodes.asLibrary(), errorMessage = null, /* TODO */ ) }.catch { throwable -> @@ -172,7 +140,7 @@ class HomeViewModel( } } - fun onCategorySelected(category: Category) { + fun onCategorySelected(category: CategoryInfo) { _selectedCategory.value = category } @@ -180,30 +148,44 @@ 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: PodcastInfo?) { + selectedLibraryPodcast.value = podcast + } + + fun onQueueEpisode(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } } +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 discoverViewState: DiscoverViewState = DiscoverViewState(), - val podcastCategoryViewState: PodcastCategoryViewState = PodcastCategoryViewState(), - val libraryEpisodes: List = emptyList(), + val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), + val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), + 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 3a6d96ecc3..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 @@ -16,47 +16,39 @@ 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.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 -import kotlinx.collections.immutable.toPersistentList 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 - } -}.toPersistentList() - val PreviewEpisodes = listOf( - Episode( + EpisodeInfo( uri = "fakeUri://episode/1", - podcastUri = PreviewPodcasts[0].uri, - 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( @@ -66,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 e8f3528724..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 @@ -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,260 +25,112 @@ 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.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlaylistAdd -import androidx.compose.material.icons.rounded.PlayCircleFilled -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -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.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.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 import com.example.jetcaster.ui.theme.JetcasterTheme -import com.example.jetcaster.ui.theme.Keyline1 import com.example.jetcaster.util.ToggleFollowPodcastIconButton -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle +import com.example.jetcaster.util.fullWidthItem -data class PodcastCategoryViewState( - val topPodcasts: List = emptyList(), - val episodes: List = emptyList() -) fun LazyListScope.podcastCategory( - topPodcasts: List, - episodes: List, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, ) { item { - CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) + 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.fillParentMaxWidth() ) } } +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, - onTogglePodcastFollowed: (String) -> Unit + topPodcasts: List, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit ) { CategoryPodcastRow( podcasts = topPodcasts, onTogglePodcastFollowed = onTogglePodcastFollowed, + navigateToPodcastDetails = navigateToPodcastDetails, modifier = Modifier.fillMaxWidth() ) } -@Composable -fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - onClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() - - Divider( - 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.subtitle1, - 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) - - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = podcast.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.subtitle2, - 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(LocalContentColor.current), - 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) - } - ) - - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = when { - episode.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() - ) - } - // Otherwise we just use the date - else -> MediumDateFormatter.format(episode.published) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.caption, - 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 = { /* TODO */ }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.Default.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add) - ) - } - - 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) - ) - } - } - } -} - @Composable private fun CategoryPodcastRow( - podcasts: List, - onTogglePodcastFollowed: (String) -> Unit, + podcasts: List, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, modifier: Modifier = Modifier ) { val lastIndex = podcasts.size - 1 @@ -288,14 +138,18 @@ 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) }, - modifier = Modifier.width(128.dp) + isFollowed = podcast.isSubscribed ?: false, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast) }, + modifier = Modifier.width(128.dp).clickable { + navigateToPodcastDetails(podcast) + } ) if (index < lastIndex) Spacer(Modifier.width(24.dp)) @@ -343,7 +197,7 @@ private fun TopPodcastRowItem( Text( text = podcastTitle, - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier @@ -353,10 +207,6 @@ private fun TopPodcastRowItem( } } -private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) -} - @Preview @Composable fun PreviewEpisodeListItem() { @@ -365,6 +215,7 @@ fun PreviewEpisodeListItem() { episode = PreviewEpisodes[0], podcast = PreviewPodcasts[0], onClick = { }, + 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 638ea2fb24..4d630242e4 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 @@ -16,38 +16,49 @@ package com.example.jetcaster.ui.home.discover +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ScrollableTabRow -import androidx.compose.material.Surface -import androidx.compose.material.Tab -import androidx.compose.material.TabPosition -import androidx.compose.material.Text +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 +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabPosition +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.data.Category -import com.example.jetcaster.ui.home.category.PodcastCategoryViewState +import com.example.jetcaster.R +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.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.ui.theme.Keyline1 - -data class DiscoverViewState( - val categories: List = emptyList(), - val selectedCategory: Category? = null -) +import com.example.jetcaster.util.fullWidthItem fun LazyListScope.discoverItems( - discoverViewState: DiscoverViewState, - podcastCategoryViewState: PodcastCategoryViewState, - navigateToPlayer: (String) -> Unit, - onCategorySelected: (Category) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, ) { - if (discoverViewState.categories.isEmpty() || discoverViewState.selectedCategory == null) { + if (filterableCategoriesModel.isEmpty) { // TODO: empty state return } @@ -56,8 +67,42 @@ fun LazyListScope.discoverItems( Spacer(Modifier.height(8.dp)) PodcastCategoryTabs( - categories = discoverViewState.categories, - selectedCategory = discoverViewState.selectedCategory, + filterableCategoriesModel = filterableCategoriesModel, + onCategorySelected = onCategorySelected, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + } + + podcastCategory( + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, + ) +} + +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() ) @@ -66,10 +111,11 @@ fun LazyListScope.discoverItems( } podcastCategory( - topPodcasts = podcastCategoryViewState.topPodcasts, - episodes = podcastCategoryViewState.episodes, + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, ) } @@ -77,20 +123,22 @@ private val emptyTabIndicator: @Composable (List) -> Unit = {} @Composable private fun PodcastCategoryTabs( - categories: List, - selectedCategory: Category, - onCategorySelected: (Category) -> Unit, + filterableCategoriesModel: FilterableCategoriesModel, + onCategorySelected: (CategoryInfo) -> Unit, modifier: Modifier = Modifier ) { - val selectedIndex = categories.indexOfFirst { it == selectedCategory } + val selectedIndex = filterableCategoriesModel.categories.indexOf( + filterableCategoriesModel.selectedCategory + ) ScrollableTabRow( selectedTabIndex = selectedIndex, + containerColor = Color.Transparent, divider = {}, /* Disable the built-in divider */ edgePadding = Keyline1, indicator = emptyTabIndicator, modifier = modifier ) { - categories.forEachIndexed { index, category -> + filterableCategoriesModel.categories.forEachIndexed { index, category -> Tab( selected = index == selectedIndex, onClick = { onCategorySelected(category) } @@ -113,20 +161,39 @@ private fun ChoiceChipContent( ) { Surface( color = when { - selected -> MaterialTheme.colors.primary.copy(alpha = 0.08f) - else -> MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + selected -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceContainer }, contentColor = when { - selected -> MaterialTheme.colors.primary - else -> MaterialTheme.colors.onSurface + selected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant }, - shape = MaterialTheme.shapes.small, + shape = MaterialTheme.shapes.medium, modifier = modifier ) { - Text( - text = text, - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + horizontal = when { + selected -> 8.dp + else -> 16.dp + }, + vertical = 8.dp + ) + ) { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(id = R.string.cd_selected_category), + modifier = Modifier + .height(18.dp) + .padding(end = 8.dp) + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } } } 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..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 @@ -16,27 +16,93 @@ 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.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 -import com.example.jetcaster.data.EpisodeToPodcast -import com.example.jetcaster.ui.home.category.EpisodeListItem +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.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( - episodes: List, - navigateToPlayer: (String) -> Unit + library: LibraryInfo, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit ) { - if (episodes.isEmpty()) { + val podcast = library.podcast + if (podcast == null || library.episodes.isEmpty()) { // TODO: Empty state return } - items(episodes, key = { it.episode.uri }) { item -> + item { + Text( + text = stringResource(id = R.string.latest_episodes), + modifier = Modifier.padding( + start = Keyline1, + top = 16.dp, + ), + style = MaterialTheme.typography.titleLarge, + ) + } + + items( + library.episodes, + key = { it.uri } + ) { item -> + EpisodeListItem( + episode = item, + podcast = podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillParentMaxWidth(), + ) + } +} + +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.episode, - podcast = item.podcast, + episode = item, + podcast = podcast, onClick = navigateToPlayer, - modifier = Modifier.fillParentMaxWidth() + onQueueEpisode = onQueueEpisode, + 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 f5a3ae922b..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 @@ -18,7 +18,9 @@ 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 import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -40,35 +42,37 @@ 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.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Slider -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Forward30 +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlaylistAdd 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.PlayCircleFilled -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +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 +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect +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 +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -77,43 +81,60 @@ 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.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 import coil.request.ImageRequest import com.example.jetcaster.R +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.ui.theme.MinContrastOfPrimaryVsSurface -import com.example.jetcaster.util.DynamicThemePrimaryColorsFromImage -import com.example.jetcaster.util.contrastAgainst import com.example.jetcaster.util.isBookPosture import com.example.jetcaster.util.isSeparatingPosture import com.example.jetcaster.util.isTableTopPosture -import com.example.jetcaster.util.rememberDominantColorState import com.example.jetcaster.util.verticalGradientScrim import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.VerticalTwoPaneStrategy import java.time.Duration +import kotlinx.coroutines.launch /** * Stateful version of the Podcast player */ @Composable fun PlayerScreen( - viewModel: PlayerViewModel, windowSizeClass: WindowSizeClass, displayFeatures: List, - onBackPress: () -> Unit + onBackPress: () -> Unit, + viewModel: PlayerViewModel = hiltViewModel(), ) { val uiState = viewModel.uiState - PlayerScreen(uiState, windowSizeClass, displayFeatures, onBackPress) + PlayerScreen( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onPlayPress = viewModel::onPlay, + onPausePress = viewModel::onPause, + onAdvanceBy = viewModel::onAdvanceBy, + onRewindBy = viewModel::onRewindBy, + onStop = viewModel::onStop, + onNext = viewModel::onNext, + onPrevious = viewModel::onPrevious, + onAddToQueue = viewModel::onAddToQueue, + ) } /** @@ -125,86 +146,213 @@ private fun PlayerScreen( windowSizeClass: WindowSizeClass, displayFeatures: List, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onStop: () -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { - Surface(modifier) { - if (uiState.podcastName.isNotEmpty()) { - PlayerContent(uiState, windowSizeClass, displayFeatures, onBackPress) + DisposableEffect(Unit) { + onDispose { + onStop() + } + } + + val coroutineScope = rememberCoroutineScope() + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + modifier = modifier + ) { contentPadding -> + if (uiState.episodePlayerState.currentEpisode != null) { + PlayerContentWithBackground( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + 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() } } } +@Composable +private fun PlayerBackground( + episode: PlayerEpisode?, + modifier: Modifier, +) { + ImageBackgroundColorScrim( + url = episode?.podcastImageUrl, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + modifier = modifier, + ) +} + +@Composable +fun PlayerContentWithBackground( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onAddToQueue: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + PlayerBackground( + episode = uiState.episodePlayerState.currentEpisode, + modifier = Modifier.fillMaxSize() + ) + PlayerContent( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + onAddToQueue = onAddToQueue, + ) + } +} + @Composable fun PlayerContent( uiState: PlayerUiState, windowSizeClass: WindowSizeClass, displayFeatures: List, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { - PlayerDynamicTheme(uiState.podcastImageUrl) { - val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() + val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() - // Use a two pane layout if there is a fold impacting layout (meaning it is separating - // or non-flat) or if we have a large enough width to show both. - if ( - windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || - isBookPosture(foldingFeature) || + // Use a two pane layout if there is a fold impacting layout (meaning it is separating + // or non-flat) or if we have a large enough width to show both. + if ( + windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED || + isBookPosture(foldingFeature) || + isTableTopPosture(foldingFeature) || + isSeparatingPosture(foldingFeature) + ) { + // Determine if we are going to be using a vertical strategy (as if laying out + // both sides in a column). We want to do so if we are in a tabletop posture, + // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. + val usingVerticalStrategy = isTableTopPosture(foldingFeature) || - isSeparatingPosture(foldingFeature) - ) { - // Determine if we are going to be using a vertical strategy (as if laying out - // both sides in a column). We want to do so if we are in a tabletop posture, - // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. - val usingVerticalStrategy = - isTableTopPosture(foldingFeature) || - ( - isSeparatingPosture(foldingFeature) && - foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL - ) + ( + isSeparatingPosture(foldingFeature) && + foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL + ) - if (usingVerticalStrategy) { + if (usingVerticalStrategy) { + TwoPane( + first = { + PlayerContentTableTopTop( + uiState = uiState, + ) + }, + second = { + PlayerContentTableTopBottom( + uiState = uiState, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + onAddToQueue = onAddToQueue, + ) + }, + strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures, + modifier = modifier, + ) + } else { + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .systemBarsPadding() + .padding(horizontal = 8.dp) + ) { + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) TwoPane( first = { - PlayerContentTableTopTop(uiState = uiState) + PlayerContentBookStart(uiState = uiState) }, second = { - PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) + PlayerContentBookEnd( + uiState = uiState, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + ) }, - strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), - displayFeatures = displayFeatures, - modifier = modifier, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures ) - } else { - Column( - modifier = modifier - .fillMaxSize() - .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), - startYPercentage = 1f, - endYPercentage = 0f - ) - .systemBarsPadding() - .padding(horizontal = 8.dp) - ) { - TopAppBar(onBackPress = onBackPress) - TwoPane( - first = { - PlayerContentBookStart(uiState = uiState) - }, - second = { - PlayerContentBookEnd(uiState = uiState) - }, - strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), - displayFeatures = displayFeatures - ) - } } - } else { - PlayerContentRegular(uiState, onBackPress, modifier) } + } else { + PlayerContentRegular( + uiState = uiState, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + onAddToQueue = onAddToQueue, + modifier = modifier, + ) } } @@ -215,38 +363,63 @@ fun PlayerContent( private fun PlayerContentRegular( uiState: PlayerUiState, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { + val playerEpisode = uiState.episodePlayerState + val currentEpisode = playerEpisode.currentEpisode ?: return Column( modifier = modifier .fillMaxSize() .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), startYPercentage = 1f, endYPercentage = 0f ) .systemBarsPadding() .padding(horizontal = 8.dp) ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 8.dp) ) { 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(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) + PlayerSlider( + timeElapsed = playerEpisode.timeElapsed, + episodeDuration = currentEpisode.duration + ) + PlayerButtons( + hasNext = playerEpisode.queue.isNotEmpty(), + isPlaying = playerEpisode.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + Modifier.padding(vertical = 8.dp) + ) } Spacer(modifier = Modifier.weight(1f)) } @@ -262,11 +435,12 @@ private fun PlayerContentTableTopTop( modifier: Modifier = Modifier ) { // Content for the top part of the screen + val episode = uiState.episodePlayerState.currentEpisode ?: return Column( modifier = modifier .fillMaxWidth() .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), startYPercentage = 1f, endYPercentage = 0f ) @@ -278,7 +452,7 @@ private fun PlayerContentTableTopTop( .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - PlayerImage(uiState.podcastImageUrl) + PlayerImage(episode.podcastImageUrl) } } @@ -289,8 +463,17 @@ private fun PlayerContentTableTopTop( private fun PlayerContentTableTopBottom( uiState: PlayerUiState, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onAddToQueue: () -> 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 @@ -302,19 +485,36 @@ private fun PlayerContentTableTopBottom( .padding(horizontal = 32.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) PodcastDescription( - title = uiState.title, - podcastName = uiState.podcastName, - titleTextStyle = MaterialTheme.typography.h6 + title = episode.title, + podcastName = episode.podcastName, + titleTextStyle = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.weight(0.5f)) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(10f) ) { - PlayerButtons(playerButtonSize = 92.dp, modifier = Modifier.padding(top = 8.dp)) - PlayerSlider(uiState.duration) + PlayerButtons( + 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 = episodePlayerState.timeElapsed, + episodeDuration = episode.duration + ) } } } @@ -327,24 +527,22 @@ private fun PlayerContentBookStart( uiState: PlayerUiState, modifier: Modifier = Modifier ) { + val episode = uiState.episodePlayerState.currentEpisode ?: return Column( modifier = modifier .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 = episode.title, + name = episode.podcastName, + summary = episode.summary, ) - Spacer(modifier = Modifier.height(32.dp)) } } @@ -354,8 +552,16 @@ private fun PlayerContentBookStart( @Composable private fun PlayerContentBookEnd( uiState: PlayerUiState, + onPlayPress: () -> Unit, + 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() @@ -364,29 +570,45 @@ private fun PlayerContentBookEnd( verticalArrangement = Arrangement.SpaceAround, ) { PlayerImage( - podcastImageUrl = uiState.podcastImageUrl, + podcastImageUrl = episode.podcastImageUrl, modifier = Modifier .padding(vertical = 16.dp) .weight(1f) ) - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) + PlayerSlider( + timeElapsed = episodePlayerState.timeElapsed, + episodeDuration = episode.duration + ) + PlayerButtons( + hasNext = episodePlayerState.queue.isNotEmpty(), + isPlaying = episodePlayerState.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + Modifier.padding(vertical = 8.dp) + ) } } @Composable -private fun TopAppBar(onBackPress: () -> Unit) { +private fun TopAppBar( + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, +) { Row(Modifier.fillMaxWidth()) { IconButton(onClick = onBackPress) { Icon( - imageVector = Icons.Default.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back) ) } Spacer(Modifier.weight(1f)) - IconButton(onClick = { /* TODO */ }) { + IconButton(onClick = onAddToQueue) { Icon( - imageVector = Icons.Default.PlaylistAdd, + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.cd_add) ) } @@ -423,21 +645,21 @@ private fun PlayerImage( private fun PodcastDescription( title: String, podcastName: String, - titleTextStyle: TextStyle = MaterialTheme.typography.h5 + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall ) { Text( text = title, style = titleTextStyle, maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.basicMarquee() ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = podcastName, - style = MaterialTheme.typography.body2, - maxLines = 1 - ) - } + Text( + text = podcastName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) } @Composable @@ -445,12 +667,14 @@ private fun PodcastInformation( title: String, name: String, summary: String, - titleTextStyle: TextStyle = MaterialTheme.typography.h5, - nameTextStyle: TextStyle = MaterialTheme.typography.h3, + modifier: Modifier = Modifier, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall, + nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall, ) { Column( + modifier = modifier.padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 8.dp) ) { Text( text = name, @@ -458,121 +682,145 @@ 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.bodyMedium, + color = LocalContentColor.current + ) } } +fun Duration.formatString(): String { + val minutes = this.toMinutes().toString().padStart(2, '0') + val secondsLeft = (this.toSeconds() % 60).toString().padStart(2, '0') + return "$minutes:$secondsLeft" +} + @Composable -private fun PlayerSlider(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( + 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 + sideButtonSize: Dp = 48.dp, ) { Row( modifier = modifier.fillMaxWidth(), 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(LocalContentColor.current), - 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_reply10), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - 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 } + contentDescription = stringResource(R.string.cd_replay10), + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + modifier = sideButtonsModifier + .clickable { + onRewindBy(Duration.ofSeconds(10)) + } ) + if (isPlaying) { + Image( + imageVector = Icons.Outlined.Pause, + contentDescription = stringResource(R.string.cd_pause), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) + .clickable { + onPausePress() + } + ) + } else { + Image( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) + .clickable { + onPlayPress() + } + ) + } Image( - imageVector = Icons.Filled.Forward30, - contentDescription = stringResource(R.string.cd_forward30), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier + imageVector = Icons.Filled.Forward10, + contentDescription = stringResource(R.string.cd_forward10), + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + modifier = sideButtonsModifier + .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), - modifier = buttonsModifier + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + modifier = sideButtonsModifier + .clickable(enabled = hasNext, onClick = onNext) ) } } -/** - * Theme that updates the colors dynamically depending on the podcast image URL - */ -@Composable -private fun PlayerDynamicTheme( - podcastImageUrl: String, - content: @Composable () -> Unit -) { - val surfaceColor = MaterialTheme.colors.surface - val dominantColorState = rememberDominantColorState( - defaultColor = MaterialTheme.colors.surface - ) { color -> - // We want a color which has sufficient contrast against the surface color - color.contrastAgainst(surfaceColor) >= MinContrastOfPrimaryVsSurface - } - DynamicThemePrimaryColorsFromImage(dominantColorState) { - // Update the dominantColorState with colors coming from the podcast image URL - LaunchedEffect(podcastImageUrl) { - if (podcastImageUrl.isNotEmpty()) { - dominantColorState.updateColorsFromImageUrl(podcastImageUrl) - } else { - dominantColorState.reset() - } - } - content() - } -} - /** * Full screen circular progress indicator */ @@ -587,11 +835,33 @@ private fun FullScreenLoading(modifier: Modifier = Modifier) { } } +@Composable +private fun HtmlText( + text: String, + style: TextStyle, + color: Color +) { + val annotationString = buildAnnotatedString { + val htmlCompat = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) + append(htmlCompat) + } + SelectionContainer { + Text( + text = annotationString, + style = style, + color = color + ) + } +} + @Preview @Composable fun TopAppBarPreview() { JetcasterTheme { - TopAppBar(onBackPress = { }) + TopAppBar( + onBackPress = {}, + onAddToQueue = {}, + ) } } @@ -599,11 +869,19 @@ fun TopAppBarPreview() { @Composable fun PlayerButtonsPreview() { JetcasterTheme { - PlayerButtons() + PlayerButtons( + hasNext = false, + isPlaying = true, + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + onNext = {}, + onPrevious = {}, + ) } } -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview(device = Devices.PHONE) @Preview(device = Devices.FOLDABLE) @Preview(device = Devices.TABLET) @@ -614,13 +892,31 @@ fun PlayerScreenPreview() { BoxWithConstraints { PlayerScreen( PlayerUiState( - title = "Title", - duration = Duration.ofHours(2), - podcastName = "Podcast" + episodePlayerState = EpisodePlayerState( + currentEpisode = PlayerEpisode( + title = "Title", + duration = Duration.ofHours(2), + podcastName = "Podcast", + ), + isPlaying = false, + queue = listOf( + PlayerEpisode(), + PlayerEpisode(), + PlayerEpisode(), + ) + ), ), displayFeatures = emptyList(), - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), - onBackPress = { } + windowSizeClass = WindowSizeClass.compute(maxWidth.value, maxHeight.value), + onBackPress = { }, + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + onStop = {}, + onNext = {}, + 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 73265ee436..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 @@ -17,81 +17,92 @@ 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.Graph -import com.example.jetcaster.data.EpisodeStore -import com.example.jetcaster.data.PodcastStore +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 kotlinx.coroutines.flow.first +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch data class PlayerUiState( - val 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() ) /** * ViewModel that handles the business logic and screen state of the Player screen */ -class PlayerViewModel( +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class PlayerViewModel @Inject constructor( episodeStore: EpisodeStore, - podcastStore: PodcastStore, + private val episodePlayer: EpisodePlayer, savedStateHandle: SavedStateHandle ) : ViewModel() { // episodeUri should always be present in the PlayerViewModel. // If that's not the case, fail crashing the app! - private val episodeUri: String = Uri.decode(savedStateHandle.get("episodeUri")!!) + private val episodeUri: String = + Uri.decode(savedStateHandle.get(Screen.ARG_EPISODE_URI)!!) var uiState by mutableStateOf(PlayerUiState()) private set 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 ?: "" - ) + episodeStore.episodeAndPodcastWithUri(episodeUri).flatMapConcat { + episodePlayer.currentEpisode = it.toPlayerEpisode() + episodePlayer.playerState + }.map { + PlayerUiState(episodePlayerState = it) + }.collect { + uiState = it + } } } - /** - * 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 - } - } + fun onPlay() { + episodePlayer.play() + } + + fun onPause() { + episodePlayer.pause() + } + + fun onStop() { + episodePlayer.stop() + } + + fun onPrevious() { + episodePlayer.previous() + } + + fun onNext() { + episodePlayer.next() + } + + fun onAdvanceBy(duration: Duration) { + episodePlayer.advanceBy(duration) + } + + fun onRewindBy(duration: Duration) { + episodePlayer.rewindBy(duration) + } + + fun onAddToQueue() { + uiState.episodePlayerState.currentEpisode?.let { + episodePlayer.addToQueue(it) + } } } 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..f8db646b71 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,370 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 +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.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 +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 +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.semantics.semantics +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.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 +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 +fun PodcastDetailsScreen( + viewModel: PodcastDetailsViewModel, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + showBackButton: Boolean, + modifier: Modifier = Modifier +) { + val state by viewModel.state.collectAsStateWithLifecycle() + when (val s = state) { + is PodcastUiState.Loading -> { + PodcastDetailsLoadingScreen( + modifier = Modifier.fillMaxSize() + ) + } + is PodcastUiState.Ready -> { + PodcastDetailsScreen( + podcast = s.podcast, + episodes = s.episodes, + toggleSubscribe = viewModel::toggleSusbcribe, + onQueueEpisode = viewModel::onQueueEpisode, + navigateToPlayer = navigateToPlayer, + navigateBack = navigateBack, + showBackButton = showBackButton, + modifier = modifier, + ) + } + } +} + +@Composable +private fun PodcastDetailsLoadingScreen( + modifier: Modifier = Modifier +) { + Loading(modifier = modifier) +} + +@Composable +fun PodcastDetailsScreen( + podcast: PodcastInfo, + episodes: List, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + showBackButton: Boolean, + 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 = { + if (showBackButton) { + PodcastDetailsTopAppBar( + navigateBack = navigateBack, + modifier = Modifier.fillMaxWidth() + ) + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { contentPadding -> + PodcastDetailsContent( + podcast = podcast, + episodes = episodes, + toggleSubscribe = toggleSubscribe, + onQueueEpisode = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onQueueEpisode(it) + }, + navigateToPlayer = navigateToPlayer, + modifier = Modifier.padding(contentPadding) + ) + } +} + +@Composable +fun PodcastDetailsContent( + podcast: PodcastInfo, + episodes: List, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + modifier: Modifier = Modifier +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(362.dp), + modifier.fillMaxSize() + ) { + fullWidthItem { + PodcastDetailsHeaderItem( + podcast = podcast, + toggleSubscribe = toggleSubscribe, + modifier = Modifier.fillMaxWidth() + ) + } + items(episodes, key = { it.uri }) { episode -> + EpisodeListItem( + episode = episode, + podcast = podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillMaxWidth(), + showPodcastImage = false + ) + } + } +} + +@Composable +fun PodcastDetailsHeaderItem( + podcast: PodcastInfo, + 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 = null, + 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 = podcast.isSubscribed ?: false, + 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, + colors = ButtonDefaults.buttonColors( + containerColor = if (isSubscribed) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.secondary + ), + modifier = Modifier.semantics(mergeDescendants = true) { } + ) { + Icon( + imageVector = if (isSubscribed) + Icons.Default.Check + else + Icons.Default.Add, + contentDescription = null + ) + Text( + text = if (isSubscribed) + stringResource(id = R.string.subscribed) + else + stringResource(id = R.string.subscribe), + modifier = Modifier.padding(start = 8.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + 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], + toggleSubscribe = { }, + ) +} + +@Preview +@Composable +fun PodcastDetailsScreenPreview() { + PodcastDetailsScreen( + podcast = PreviewPodcasts[0], + episodes = PreviewEpisodes, + toggleSubscribe = { }, + onQueueEpisode = { }, + navigateToPlayer = { }, + navigateBack = { }, + showBackButton = true, + ) +} 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..858289bc0d --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcast + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.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 +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +sealed interface PodcastUiState { + data object Loading : PodcastUiState + data class Ready( + val podcast: PodcastInfo, + val episodes: List, + ) : PodcastUiState +} + +/** + * ViewModel that handles the business logic and screen state of the Podcast details screen. + */ +@HiltViewModel(assistedFactory = PodcastDetailsViewModel.Factory::class) +class PodcastDetailsViewModel @AssistedInject constructor( + private val episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, + private val podcastStore: PodcastStore, + @Assisted private val podcastUri: String, +) : ViewModel() { + + private val decodedPodcastUri = Uri.decode(podcastUri) + + val state: StateFlow = + combine( + podcastStore.podcastWithExtraInfo(decodedPodcastUri), + episodeStore.episodesInPodcast(decodedPodcastUri) + ) { podcast, episodeToPodcasts -> + val episodes = episodeToPodcasts.map { it.episode.asExternalModel() } + PodcastUiState.Ready( + podcast = podcast.podcast.asExternalModel().copy(isSubscribed = podcast.isFollowed), + episodes = episodes, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PodcastUiState.Loading + ) + + fun toggleSusbcribe(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + + fun onQueueEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } + + @AssistedFactory + interface Factory { + fun create(podcastUri: String): PodcastDetailsViewModel + } +} 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..bafb863074 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -0,0 +1,271 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.shared + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.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.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.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 coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.jetcaster.R +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 +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun EpisodeListItem( + episode: EpisodeInfo, + podcast: PodcastInfo, + onClick: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + showPodcastImage: Boolean = true, +) { + 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) + ) + + // Bottom Part + EpisodeListItemFooter( + episode = episode, + podcast = podcast, + onQueueEpisode = onQueueEpisode, + ) + } + } + } +} + +@Composable +private fun EpisodeListItemFooter( + episode: EpisodeInfo, + podcast: PodcastInfo, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp) + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button } + ) + + val duration = episode.duration + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + IconButton( + onClick = { + onQueueEpisode( + PlayerEpisode( + podcastInfo = podcast, + episodeInfo = episode + ) + ) + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton( + onClick = { /* TODO */ }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +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, + minLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + text = podcast.title, + maxLines = 2, + minLines = 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) +} 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..4b96dc6e8a --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/shared/Loading.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.shared + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun Loading(modifier: Modifier = Modifier) { + Surface(modifier = modifier) { + Box( + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + Modifier.align(Alignment.Center) + ) + } + } +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt index 03254e8269..5193851599 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt @@ -16,37 +16,9 @@ package com.example.jetcaster.ui.theme -import androidx.compose.material.Colors -import androidx.compose.material.darkColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver - /** * This is the minimum amount of calculated contrast for a color to be used on top of the * surface color. These values are defined within the WCAG AA guidelines, and we use a value of * 3:1 which is the minimum for user-interface components. */ const val MinContrastOfPrimaryVsSurface = 3f - -/** - * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the - * given [alpha]. Useful for situations where semi-transparent colors are undesirable. - */ -@Composable -fun Colors.compositedOnSurface(alpha: Float): Color { - return onSurface.copy(alpha = alpha).compositeOver(surface) -} - -val Yellow800 = Color(0xFFF29F05) -val Red300 = Color(0xFFEA6D7E) - -val JetcasterColors = darkColors( - primary = Yellow800, - onPrimary = Color.Black, - primaryVariant = Yellow800, - secondary = Yellow800, - onSecondary = Color.Black, - error = Red300, - onError = Color.Black -) 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..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 @@ -16,17 +16,504 @@ package com.example.jetcaster.ui.theme -import androidx.compose.material.MaterialTheme +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.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.JetcasterShapes +import com.example.jetcaster.designsystem.theme.JetcasterTypography +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.backgroundDarkHighContrast +import com.example.jetcaster.designsystem.theme.backgroundDarkMediumContrast +import com.example.jetcaster.designsystem.theme.backgroundLight +import com.example.jetcaster.designsystem.theme.backgroundLightHighContrast +import com.example.jetcaster.designsystem.theme.backgroundLightMediumContrast +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.errorContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.errorContainerLight +import com.example.jetcaster.designsystem.theme.errorContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.errorContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.errorDarkHighContrast +import com.example.jetcaster.designsystem.theme.errorDarkMediumContrast +import com.example.jetcaster.designsystem.theme.errorLight +import com.example.jetcaster.designsystem.theme.errorLightHighContrast +import com.example.jetcaster.designsystem.theme.errorLightMediumContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryDark +import com.example.jetcaster.designsystem.theme.inversePrimaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryLight +import com.example.jetcaster.designsystem.theme.inversePrimaryLightHighContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceLight +import com.example.jetcaster.designsystem.theme.inverseSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.onBackgroundDark +import com.example.jetcaster.designsystem.theme.onBackgroundDarkHighContrast +import com.example.jetcaster.designsystem.theme.onBackgroundDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onBackgroundLight +import com.example.jetcaster.designsystem.theme.onBackgroundLightHighContrast +import com.example.jetcaster.designsystem.theme.onBackgroundLightMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerDark +import com.example.jetcaster.designsystem.theme.onErrorContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerLight +import com.example.jetcaster.designsystem.theme.onErrorContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onErrorDarkHighContrast +import com.example.jetcaster.designsystem.theme.onErrorDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorLight +import com.example.jetcaster.designsystem.theme.onErrorLightHighContrast +import com.example.jetcaster.designsystem.theme.onErrorLightMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onPrimaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryLight +import com.example.jetcaster.designsystem.theme.onPrimaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryLight +import com.example.jetcaster.designsystem.theme.onSecondaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceDark +import com.example.jetcaster.designsystem.theme.onSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceLight +import com.example.jetcaster.designsystem.theme.onSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.onTertiaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryLight +import com.example.jetcaster.designsystem.theme.onTertiaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineDarkHighContrast +import com.example.jetcaster.designsystem.theme.outlineDarkMediumContrast +import com.example.jetcaster.designsystem.theme.outlineLight +import com.example.jetcaster.designsystem.theme.outlineLightHighContrast +import com.example.jetcaster.designsystem.theme.outlineLightMediumContrast +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.outlineVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.outlineVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.outlineVariantLight +import com.example.jetcaster.designsystem.theme.outlineVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.outlineVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.primaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.primaryContainerLight +import com.example.jetcaster.designsystem.theme.primaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.primaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.primaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.primaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.primaryLight +import com.example.jetcaster.designsystem.theme.primaryLightHighContrast +import com.example.jetcaster.designsystem.theme.primaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.scrimDark +import com.example.jetcaster.designsystem.theme.scrimDarkHighContrast +import com.example.jetcaster.designsystem.theme.scrimDarkMediumContrast +import com.example.jetcaster.designsystem.theme.scrimLight +import com.example.jetcaster.designsystem.theme.scrimLightHighContrast +import com.example.jetcaster.designsystem.theme.scrimLightMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerLight +import com.example.jetcaster.designsystem.theme.secondaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.secondaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.secondaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryLight +import com.example.jetcaster.designsystem.theme.secondaryLightHighContrast +import com.example.jetcaster.designsystem.theme.secondaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightDark +import com.example.jetcaster.designsystem.theme.surfaceBrightDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightLight +import com.example.jetcaster.designsystem.theme.surfaceBrightLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerDark +import com.example.jetcaster.designsystem.theme.surfaceContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDark +import com.example.jetcaster.designsystem.theme.surfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDimDark +import com.example.jetcaster.designsystem.theme.surfaceDimDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDimDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDimLight +import com.example.jetcaster.designsystem.theme.surfaceDimLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDimLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceLight +import com.example.jetcaster.designsystem.theme.surfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantLight +import com.example.jetcaster.designsystem.theme.surfaceVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerDark +import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerLight +import com.example.jetcaster.designsystem.theme.tertiaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryDark +import com.example.jetcaster.designsystem.theme.tertiaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryLight +import com.example.jetcaster.designsystem.theme.tertiaryLightHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryLightMediumContrast + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) + +val unspecified_scheme = ColorFamily( + Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified +) @Composable fun JetcasterTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkScheme + else -> lightScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = Color.Transparent.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + MaterialTheme( - colors = JetcasterColors, - typography = JetcasterTypography, + colorScheme = colorScheme, shapes = JetcasterShapes, + typography = JetcasterTypography, 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 deleted file mode 100644 index 1c407e52cb..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt +++ /dev/null @@ -1,122 +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.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) -) - -val JetcasterTypography = Typography( - h1 = TextStyle( - fontFamily = Montserrat, - fontSize = 96.sp, - fontWeight = FontWeight.Light, - lineHeight = 117.sp, - letterSpacing = (-1.5).sp - ), - h2 = TextStyle( - fontFamily = Montserrat, - fontSize = 60.sp, - fontWeight = FontWeight.Light, - lineHeight = 73.sp, - letterSpacing = (-0.5).sp - ), - h3 = TextStyle( - fontFamily = Montserrat, - fontSize = 48.sp, - fontWeight = FontWeight.Normal, - lineHeight = 59.sp - ), - h4 = TextStyle( - fontFamily = Montserrat, - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 37.sp - ), - h5 = TextStyle( - fontFamily = Montserrat, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 29.sp - ), - h6 = TextStyle( - fontFamily = Montserrat, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp - ), - subtitle1 = TextStyle( - fontFamily = Montserrat, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 20.sp, - letterSpacing = 0.5.sp - ), - subtitle2 = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - lineHeight = 17.sp, - letterSpacing = 0.1.sp - ), - body1 = TextStyle( - fontFamily = Montserrat, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - lineHeight = 20.sp, - letterSpacing = 0.15.sp - ), - body2 = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - button = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 1.25.sp - ), - caption = TextStyle( - fontFamily = Montserrat, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 0.sp - ), - overline = TextStyle( - fontFamily = Montserrat, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 1.sp - ) -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt index 2fe99a0c1a..c90ffc9d82 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt @@ -20,18 +20,16 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics @@ -63,8 +61,8 @@ fun ToggleFollowPodcastIconButton( }, tint = animateColorAsState( when { - isFollowed -> LocalContentColor.current - else -> Color.Black.copy(alpha = ContentAlpha.high) + isFollowed -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.primary } ).value, modifier = Modifier @@ -75,11 +73,11 @@ fun ToggleFollowPodcastIconButton( .background( color = animateColorAsState( when { - isFollowed -> MaterialTheme.colors.surface.copy(0.38f) - else -> Color.White + isFollowed -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceContainerHighest } ).value, - shape = MaterialTheme.shapes.small + shape = CircleShape ) .padding(4.dp) ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt deleted file mode 100644 index 4cead93b60..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt +++ /dev/null @@ -1,185 +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.util - -import android.content.Context -import androidx.collection.LruCache -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.core.graphics.drawable.toBitmap -import androidx.palette.graphics.Palette -import coil.imageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.size.Scale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@Composable -fun rememberDominantColorState( - context: Context = LocalContext.current, - defaultColor: Color = MaterialTheme.colors.primary, - defaultOnColor: Color = MaterialTheme.colors.onPrimary, - cacheSize: Int = 12, - isColorValid: (Color) -> Boolean = { true } -): DominantColorState = remember { - DominantColorState(context, defaultColor, defaultOnColor, cacheSize, isColorValid) -} - -/** - * A composable which allows dynamic theming of the [androidx.compose.material.Colors.primary] - * color from an image. - */ -@Composable -fun DynamicThemePrimaryColorsFromImage( - dominantColorState: DominantColorState = rememberDominantColorState(), - content: @Composable () -> Unit -) { - val colors = MaterialTheme.colors.copy( - primary = animateColorAsState( - dominantColorState.color, - spring(stiffness = Spring.StiffnessLow) - ).value, - onPrimary = animateColorAsState( - dominantColorState.onColor, - spring(stiffness = Spring.StiffnessLow) - ).value - ) - MaterialTheme(colors = colors, content = content) -} - -/** - * A class which stores and caches the result of any calculated dominant colors - * from images. - * - * @param context Android context - * @param defaultColor The default color, which will be used if [calculateDominantColor] fails to - * calculate a dominant color - * @param defaultOnColor The default foreground 'on color' for [defaultColor]. - * @param cacheSize The size of the [LruCache] used to store recent results. Pass `0` to - * disable the cache. - * @param isColorValid A lambda which allows filtering of the calculated image colors. - */ -@Stable -class DominantColorState( - private val context: Context, - private val defaultColor: Color, - private val defaultOnColor: Color, - cacheSize: Int = 12, - private val isColorValid: (Color) -> Boolean = { true } -) { - var color by mutableStateOf(defaultColor) - private set - var onColor by mutableStateOf(defaultOnColor) - private set - - private val cache = when { - cacheSize > 0 -> LruCache(cacheSize) - else -> null - } - - suspend fun updateColorsFromImageUrl(url: String) { - val result = calculateDominantColor(url) - color = result?.color ?: defaultColor - onColor = result?.onColor ?: defaultOnColor - } - - private suspend fun calculateDominantColor(url: String): DominantColors? { - val cached = cache?.get(url) - if (cached != null) { - // If we already have the result cached, return early now... - return cached - } - - // Otherwise we calculate the swatches in the image, and return the first valid color - return calculateSwatchesInImage(context, url) - // First we want to sort the list by the color's population - .sortedByDescending { swatch -> swatch.population } - // Then we want to find the first valid color - .firstOrNull { swatch -> isColorValid(Color(swatch.rgb)) } - // If we found a valid swatch, wrap it in a [DominantColors] - ?.let { swatch -> - DominantColors( - color = Color(swatch.rgb), - onColor = Color(swatch.bodyTextColor).copy(alpha = 1f) - ) - } - // Cache the resulting [DominantColors] - ?.also { result -> cache?.put(url, result) } - } - - /** - * Reset the color values to [defaultColor]. - */ - fun reset() { - color = defaultColor - onColor = defaultColor - } -} - -@Immutable -private data class DominantColors(val color: Color, val onColor: Color) - -/** - * Fetches the given [imageUrl] with Coil, then uses [Palette] to calculate the dominant color. - */ -private suspend fun calculateSwatchesInImage( - context: Context, - imageUrl: String -): List { - val request = ImageRequest.Builder(context) - .data(imageUrl) - // We scale the image to cover 128px x 128px (i.e. min dimension == 128px) - .size(128).scale(Scale.FILL) - // Disable hardware bitmaps, since Palette uses Bitmap.getPixels() - .allowHardware(false) - // Set a custom memory cache key to avoid overwriting the displayed image in the cache - .memoryCacheKey("$imageUrl.palette") - .build() - - val bitmap = when (val result = context.imageLoader.execute(request)) { - is SuccessResult -> result.drawable.toBitmap() - else -> null - } - - return bitmap?.let { - withContext(Dispatchers.Default) { - val palette = Palette.Builder(bitmap) - // Disable any bitmap resizing in Palette. We've already loaded an appropriately - // sized bitmap through Coil - .resizeBitmapArea(0) - // Clear any built-in filters. We want the unfiltered dominant color - .clearFilters() - // We reduce the maximum color count down to 8 - .maximumColorCount(8) - .generate() - - palette.swatches - } - } ?: emptyList() -} 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 5c6a996361..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,12 +17,17 @@ package com.example.jetcaster.util import androidx.annotation.FloatRange -import androidx.compose.ui.ExperimentalComposeUiApi +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 @@ -32,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. * @@ -113,7 +137,6 @@ private data class VerticalGradientElement( } } -@OptIn(ExperimentalComposeUiApi::class) private class VerticalGradientModifier( var onDraw: DrawScope.() -> Unit ) : Modifier.Node(), DrawModifierNode { 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..6233653f67 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable + +/** + * An item that occupies the entire width. + */ +fun LazyGridScope.fullWidthItem( + key: Any? = null, + contentType: Any? = null, + content: @Composable LazyGridItemScope.() -> Unit +) = item( + span = { GridItemSpan(this.maxLineSpan) }, + key = key, + contentType = contentType, + content = content +) 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..b4c90b3729 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass + +/** + * Returns true if the width or height size classes are compact. + */ +val WindowSizeClass.isCompact: Boolean + get() = windowWidthSizeClass == WindowWidthSizeClass.COMPACT || + windowHeightSizeClass == WindowHeightSizeClass.COMPACT diff --git a/Jetcaster/app/src/main/res/drawable/ic_launcher_background.xml b/Jetcaster/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..7f2643db2d --- /dev/null +++ b/Jetcaster/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/app/src/main/res/drawable/ic_launcher_foreground.xml b/Jetcaster/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..c19b699858 --- /dev/null +++ b/Jetcaster/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/app/src/main/res/drawable/ic_launcher_monochrome.xml b/Jetcaster/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..e71686aef8 --- /dev/null +++ b/Jetcaster/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/app/src/main/res/drawable/ic_logo.xml b/Jetcaster/app/src/main/res/drawable/ic_logo.xml index 6ffb57e780..8d00d29968 100644 --- a/Jetcaster/app/src/main/res/drawable/ic_logo.xml +++ b/Jetcaster/app/src/main/res/drawable/ic_logo.xml @@ -1,5 +1,5 @@ - - - - + + diff --git a/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..96e4ade2ed --- /dev/null +++ b/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/Jetcaster/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Jetcaster/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Jetcaster/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Jetcaster/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Jetcaster/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Jetcaster/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Jetcaster/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Jetcaster/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Jetcaster/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index f5bc5c0286..50721da443 100644 Binary files a/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..50721da443 Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index c2cd845503..d21cc705a0 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -40,19 +40,27 @@ %1$s • %2$d mins - Search Account Add Back + Follow + Following + Forward 10 seconds More + Not following + Pause Play - Skip previous - Reply 10 seconds - Forward 30 seconds + Replay 10 seconds + Search + Selected category Skip next + Skip previous Unfollow - Follow - Following - Not following + Episode added to your queue + Podcast image + Subscribe + Subscribed + see more + Search for a podcast diff --git a/Jetcaster/build.gradle.kts b/Jetcaster/build.gradle.kts index c83db36f52..d3fc5aca1b 100644 --- a/Jetcaster/build.gradle.kts +++ b/Jetcaster/build.gradle.kts @@ -15,8 +15,13 @@ */ 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) + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false } -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..402d1e1d41 --- /dev/null +++ b/Jetcaster/core/build.gradle.kts @@ -0,0 +1,72 @@ +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 +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(project(":core:model")) + + // Image loading + implementation(libs.coil.kt.compose) + + // Compose + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Networking + implementation(libs.okhttp3) + implementation(libs.okhttp.logging) + + // Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.rometools.rome) + implementation(libs.rometools.modules) + + coreLibraryDesugaring(libs.core.jdk.desugaring) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} 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/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/app/src/main/java/com/example/jetcaster/util/Pager.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt similarity index 77% rename from Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt rename to Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt index 1556286ea6..9ebf1a9577 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.example.jetcaster.util +package com.example.jetcaster.core.model -/** - * Pager is now a library! https://google.github.io/accompanist/pager/ - */ +data class CategoryInfo( + val id: Long, + val name: String +) diff --git a/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt new file mode 100644 index 0000000000..88b2d1f158 --- /dev/null +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import 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, +) diff --git a/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt new file mode 100644 index 0000000000..4cca646940 --- /dev/null +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.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.core.model + +/** + * Model holding a list of categories and a selected category in the collection + */ +data class FilterableCategoriesModel( + val categories: List = emptyList(), + val selectedCategory: CategoryInfo? = null +) { + val isEmpty = categories.isEmpty() || selectedCategory == null +} diff --git a/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt new file mode 100644 index 0000000000..a502a0bb29 --- /dev/null +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +data class LibraryInfo( + val podcast: PodcastInfo? = null, + val episodes: List = emptyList() +) diff --git a/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PlayerEpisode.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PlayerEpisode.kt new file mode 100644 index 0000000000..7b4c7d4ad2 --- /dev/null +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PlayerEpisode.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import java.time.Duration +import java.time.OffsetDateTime + +/** + * Episode data with necessary information to be used within a player. + */ +data class PlayerEpisode( + val uri: String = "", + val title: String = "", + val subTitle: String = "", + val published: OffsetDateTime = OffsetDateTime.MIN, + val duration: Duration? = null, + val podcastName: String = "", + val author: String = "", + val summary: String = "", + val podcastImageUrl: String = "", +) { + constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( + title = episodeInfo.title, + subTitle = episodeInfo.subTitle, + published = episodeInfo.published, + duration = episodeInfo.duration, + podcastName = podcastInfo.title, + author = episodeInfo.author, + summary = episodeInfo.summary, + podcastImageUrl = podcastInfo.imageUrl, + uri = episodeInfo.uri + ) +} diff --git a/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt new file mode 100644 index 0000000000..e1d27306ed --- /dev/null +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +/** + * A model holding top podcasts and matching episodes when filtering based on a category. + */ +data class PodcastCategoryFilterResult( + val topPodcasts: List = emptyList(), + val episodes: List = emptyList() +) + +data class PodcastCategoryEpisode( + val episode: EpisodeInfo, + val podcast: PodcastInfo, +) diff --git a/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt new file mode 100644 index 0000000000..5aced90656 --- /dev/null +++ b/Jetcaster/core/model/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import java.time.OffsetDateTime + +/** + * External data layer representation of a podcast. + */ +data class PodcastInfo( + val uri: String = "", + val title: String = "", + val author: String = "", + val imageUrl: String = "", + val description: String = "", + val isSubscribed: Boolean? = null, + val lastEpisodeDate: OffsetDateTime? = null, +) 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/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..a57199979c --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class Dispatcher(val jetcasterDispatcher: JetcasterDispatchers) + +enum class JetcasterDispatchers { + Main, + IO, +} 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..ced5d408b0 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.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/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 69% 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..e1d60d5f07 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 /** @@ -36,6 +36,17 @@ abstract class EpisodesDao : BaseDao { ) abstract fun episode(uri: String): Flow + @Transaction + @Query( + """ + SELECT episodes.* FROM episodes + INNER JOIN podcasts ON episodes.podcast_uri = podcasts.uri + WHERE episodes.uri = :episodeUri + """ + ) + abstract fun episodeAndPodcast(episodeUri: String): Flow + + @Transaction @Query( """ SELECT * FROM episodes WHERE podcast_uri = :podcastUri @@ -65,4 +76,17 @@ abstract class EpisodesDao : BaseDao { @Query("SELECT COUNT(*) FROM episodes") abstract suspend fun count(): Int + + @Transaction + @Query( + """ + SELECT * FROM episodes WHERE podcast_uri IN (:podcastUris) + ORDER BY datetime(published) DESC + LIMIT :limit + """ + ) + abstract fun episodesForPodcasts( + podcastUris: List, + limit: Int + ): 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 56% 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..4d5ce71755 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 /** @@ -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( """ @@ -89,6 +106,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/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 83% 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..4b90f4b1c8 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,13 +14,14 @@ * 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 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/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 82% 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..cf9ae998e5 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 @@ -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/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 63% 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..4646849aca 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,11 +14,13 @@ * 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 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/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 78% 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..642759db3c 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,13 +14,14 @@ * 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 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/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 96% 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..420e68f38f 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 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 84% 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..e76c4b22f2 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,10 +14,11 @@ * 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 +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/di/CoreDiModule.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt new file mode 100644 index 0000000000..7878b9be97 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/CoreDiModule.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.di + +import android.content.Context +import androidx.room.Room +import coil.ImageLoader +import com.example.jetcaster.core.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 java.io.File +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.logging.LoggingEventListener + +@Module +@InstallIn(SingletonComponent::class) +object 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/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.kt new file mode 100644 index 0000000000..cd55b68a8a --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCase.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.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 javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Use case for categories that can be used to filter podcasts. + */ +class FilterableCategoriesUseCase @Inject constructor( + private val categoryStore: CategoryStore +) { + /** + * Created a [FilterableCategoriesModel] from the list of categories in [categoryStore]. + * @param selectedCategory the currently selected category. If null, the first category + * returned by the backing category list will be selected in the returned + * FilterableCategoriesModel + */ + operator fun invoke(selectedCategory: CategoryInfo?): Flow = + categoryStore.categoriesSortedByPodcastCount() + .map { categories -> + FilterableCategoriesModel( + categories = categories.map { it.asExternalModel() }, + selectedCategory = selectedCategory + ?: categories.firstOrNull()?.asExternalModel() + ) + } +} 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 new file mode 100644 index 0000000000..8d87799302 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCase.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.domain + +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +/** + * A use case which returns all the latest episodes from all the podcasts the user follows. + */ +class GetLatestFollowedEpisodesUseCase @Inject constructor( + private val episodeStore: EpisodeStore, + private val podcastStore: PodcastStore, +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(): Flow> = + podcastStore.followedPodcastsSortedByLastEpisode() + .flatMapLatest { followedPodcasts -> + episodeStore.episodesInPodcasts( + followedPodcasts.map { it.podcast.uri }, + followedPodcasts.size * 5 + ) + } +} 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 new file mode 100644 index 0000000000..71e3d160a3 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCase.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.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.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 + +/** + * A use case which returns top podcasts and matching episodes in a given [Category]. + */ +class PodcastCategoryFilterUseCase @Inject constructor( + private val categoryStore: CategoryStore +) { + operator fun invoke(category: CategoryInfo?): Flow { + if (category == null) { + return flowOf(PodcastCategoryFilterResult()) + } + + val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount( + category.id, + limit = 10 + ) + + val episodesFlow = categoryStore.episodesFromPodcastsInCategory( + category.id, + limit = 20 + ) + + // Combine our flows and collect them into the view state StateFlow + return combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes -> + PodcastCategoryFilterResult( + topPodcasts = topPodcasts.map { it.asExternalModel() }, + episodes = episodes.map { it.asPodcastCategoryEpisode() } + ) + } + } +} 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..147fed436e 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,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network import java.io.IOException import kotlin.coroutines.resumeWithException 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 91% 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..34e7030c93 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,14 @@ * 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.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.Podcast import com.rometools.modules.itunes.EntryInformation import com.rometools.modules.itunes.FeedInformation import com.rometools.rome.feed.synd.SyndEntry @@ -26,6 +31,7 @@ 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 @@ -44,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/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 56% 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..a69d082652 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,30 +14,69 @@ * 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.dao.CategoriesDao +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import kotlinx.coroutines.flow.Flow +interface CategoryStore { + /** + * Returns a flow containing a list of categories which is sorted by the number + * of podcasts in each category. + */ + fun categoriesSortedByPodcastCount( + limit: Int = Integer.MAX_VALUE + ): Flow> + + /** + * Returns a flow containing a list of podcasts in the category with the given [categoryId], + * sorted by the their last episode date. + */ + fun podcastsInCategorySortedByPodcastCount( + categoryId: Long, + limit: Int = Int.MAX_VALUE + ): Flow> + + /** + * Returns a flow containing a list of episodes from podcasts in the category with the + * given [categoryId], sorted by the their last episode date. + */ + fun episodesFromPodcastsInCategory( + categoryId: Long, + limit: Int = Integer.MAX_VALUE + ): Flow> + + /** + * Adds the category to the database if it doesn't already exist. + * + * @return the id of the newly inserted/existing category + */ + suspend fun addCategory(category: Category): Long + + suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) +} + /** * A data repository for [Category] instances. */ -class CategoryStore( +class LocalCategoryStore constructor( private val categoriesDao: CategoriesDao, private val categoryEntryDao: PodcastCategoryEntryDao, private val episodesDao: EpisodesDao, private val podcastsDao: PodcastsDao -) { +) : CategoryStore { /** * Returns a flow containing a list of categories which is sorted by the number * of podcasts in each category. */ - fun categoriesSortedByPodcastCount( - limit: Int = Integer.MAX_VALUE - ): Flow> { + override fun categoriesSortedByPodcastCount(limit: Int): Flow> { return categoriesDao.categoriesSortedByPodcastCount(limit) } @@ -45,9 +84,9 @@ class CategoryStore( * Returns a flow containing a list of podcasts in the category with the given [categoryId], * sorted by the their last episode date. */ - fun podcastsInCategorySortedByPodcastCount( + override fun podcastsInCategorySortedByPodcastCount( categoryId: Long, - limit: Int = Int.MAX_VALUE + limit: Int ): Flow> { return podcastsDao.podcastsInCategorySortedByLastEpisode(categoryId, limit) } @@ -56,9 +95,9 @@ class CategoryStore( * Returns a flow containing a list of episodes from podcasts in the category with the * given [categoryId], sorted by the their last episode date. */ - fun episodesFromPodcastsInCategory( + override fun episodesFromPodcastsInCategory( categoryId: Long, - limit: Int = Integer.MAX_VALUE + limit: Int ): Flow> { return episodesDao.episodesFromPodcastsInCategory(categoryId, limit) } @@ -68,14 +107,14 @@ class CategoryStore( * * @return the id of the newly inserted/existing category */ - suspend fun addCategory(category: Category): Long { + override suspend fun addCategory(category: Category): Long { return when (val local = categoriesDao.getCategoryWithName(category.name)) { null -> categoriesDao.insert(category) else -> local.id } } - suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) { + override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) { categoryEntryDao.insert( PodcastCategoryEntry(podcastUri = podcastUri, categoryId = categoryId) ) 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 new file mode 100644 index 0000000000..26af92e97c --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import kotlinx.coroutines.flow.Flow + +interface EpisodeStore { + /** + * Returns a flow containing the episode given [episodeUri]. + */ + fun episodeWithUri(episodeUri: String): Flow + + /** + * 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]. + */ + fun episodesInPodcast( + podcastUri: String, + limit: Int = Integer.MAX_VALUE + ): Flow> + + /** + * Returns a list of episodes for the given podcast URIs ordering by most recently published + * to least recently published. + */ + fun episodesInPodcasts( + podcastUris: List, + limit: Int = Integer.MAX_VALUE + ): Flow> + + /** + * Add a new [Episode] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + suspend fun addEpisodes(episodes: Collection) + + suspend fun isEmpty(): Boolean +} + +/** + * A data repository for [Episode] instances. + */ +class LocalEpisodeStore( + private val episodesDao: EpisodesDao +) : EpisodeStore { + /** + * Returns a flow containing the episode given [episodeUri]. + */ + override fun episodeWithUri(episodeUri: String): Flow { + 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]. + */ + override fun episodesInPodcast( + podcastUri: String, + limit: Int + ): Flow> { + return episodesDao.episodesForPodcastUri(podcastUri, limit) + } + /** + * Returns a list of episodes for the given podcast URIs ordering by most recently published + * to least recently published. + */ + override fun episodesInPodcasts( + podcastUris: List, + limit: Int + ): Flow> = + episodesDao.episodesForPodcasts(podcastUris, limit) + + /** + * Add a new [Episode] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + override suspend fun addEpisodes(episodes: Collection) = + episodesDao.insertAll(episodes) + + override suspend fun isEmpty(): Boolean = episodesDao.count() == 0 +} 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 new file mode 100644 index 0000000000..ee809c9e30 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow + +interface PodcastStore { + /** + * Return a flow containing the [Podcast] with the given [uri]. + */ + fun podcastWithUri(uri: String): Flow + + /** + * 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. + */ + fun podcastsSortedByLastEpisode( + limit: Int = Int.MAX_VALUE + ): Flow> + + /** + * Returns a flow containing a list of all followed podcasts, sorted by the their last + * episode date. + */ + fun followedPodcastsSortedByLastEpisode( + limit: Int = Int.MAX_VALUE + ): Flow> + + /** + * 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 followPodcast(podcastUri: String) + + suspend fun unfollowPodcast(podcastUri: String) + + /** + * Add a new [Podcast] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + suspend fun addPodcast(podcast: Podcast) + + suspend fun isEmpty(): Boolean +} + +/** + * A data repository for [Podcast] instances. + */ +class LocalPodcastStore constructor( + private val podcastDao: PodcastsDao, + private val podcastFollowedEntryDao: PodcastFollowedEntryDao, + private val transactionRunner: TransactionRunner +) : PodcastStore { + /** + * Return a flow containing the [Podcast] with the given [uri]. + */ + override fun podcastWithUri(uri: String): Flow { + 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. + */ + override fun podcastsSortedByLastEpisode( + limit: Int + ): Flow> { + return podcastDao.podcastsSortedByLastEpisode(limit) + } + + /** + * Returns a flow containing a list of all followed podcasts, sorted by the their last + * episode date. + */ + override fun followedPodcastsSortedByLastEpisode( + limit: Int + ): Flow> { + 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) + } + + override suspend fun followPodcast(podcastUri: String) { + podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri)) + } + + override suspend fun togglePodcastFollowed(podcastUri: String) = transactionRunner { + if (podcastFollowedEntryDao.isPodcastFollowed(podcastUri)) { + unfollowPodcast(podcastUri) + } else { + followPodcast(podcastUri) + } + } + + override suspend fun unfollowPodcast(podcastUri: String) { + podcastFollowedEntryDao.deleteWithPodcastUri(podcastUri) + } + + /** + * Add a new [Podcast] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + override suspend fun addPodcast(podcast: Podcast) { + podcastDao.insert(podcast) + } + + override suspend fun isEmpty(): Boolean = podcastDao.count() == 0 +} 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 81% 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..cb9b308405 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,18 @@ * 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.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.network.PodcastRssResponse +import com.example.jetcaster.core.data.network.PodcastsFetcher +import com.example.jetcaster.core.data.network.SampleFeeds +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -28,13 +33,13 @@ import kotlinx.coroutines.launch /** * 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 @@ -44,6 +49,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/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..173ac5eb73 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.flow.StateFlow + +data class EpisodePlayerState( + val currentEpisode: PlayerEpisode? = null, + val queue: List = emptyList(), + 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: PlayerEpisode? + + fun addToQueue(episode: PlayerEpisode) + + /** + * Plays the current episode + */ + fun play() + + /** + * Plays the specified episode + */ + fun play(playerEpisode: PlayerEpisode) + + /** + * Pauses the currently played episode + */ + fun pause() + + /** + * Stops the currently played episode + */ + fun stop() + + /** + * Plays another episode in the queue (if available) + */ + fun next() + + /** + * Plays the previous episode in the queue (if available). Or if an episode is currently + * playing this will start the episode from the beginning + */ + fun previous() + + /** + * Advances a currently played episode by a given time interval specified in [duration]. + */ + fun advanceBy(duration: Duration) + + /** + * Rewinds a currently played episode by a given time interval specified in [duration]. + */ + fun rewindBy(duration: Duration) +} 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..25f49baaa4 --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class MockEpisodePlayer( + private val mainDispatcher: CoroutineDispatcher +) : EpisodePlayer { + + private val _playerState = MutableStateFlow(EpisodePlayerState()) + private val _currentEpisode = MutableStateFlow(null) + private val queue = MutableStateFlow>(emptyList()) + 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, + queue, + isPlaying, + timeElapsed + ) { currentEpisode, queue, isPlaying, timeElapsed -> + EpisodePlayerState( + currentEpisode = currentEpisode, + queue = queue, + isPlaying = isPlaying, + timeElapsed = timeElapsed + ) + }.catch { + // TODO handle error state + throw it + }.collect { + _playerState.value = it + } + } + } + + override val playerState: StateFlow = _playerState.asStateFlow() + + override var currentEpisode: PlayerEpisode? by _currentEpisode + override fun addToQueue(episode: PlayerEpisode) { + queue.update { + it + episode + } + } + + 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) } + } + + // Once done playing, see if + isPlaying.value = false + timeElapsed.value = Duration.ZERO + + if (hasNext()) { + next() + } + } + } + + 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() + } + + 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() { + val q = queue.value + if (q.isEmpty()) { + return + } + + timeElapsed.value = Duration.ZERO + val nextEpisode = q[0] + currentEpisode = nextEpisode + queue.value = q - nextEpisode + play() + } + + override fun previous() { + timeElapsed.value = Duration.ZERO + isPlaying.value = false + timerJob?.cancel() + timerJob = null + } + + private fun hasNext(): Boolean { + return queue.value.isNotEmpty() + } +} + +// Used to enable property delegation +private operator fun 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/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/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 new file mode 100644 index 0000000000..1a548197ea --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/FilterableCategoriesUseCaseTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.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 kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class FilterableCategoriesUseCaseTest { + + private val categoriesStore = TestCategoryStore() + private val testCategories = listOf( + Category(1, "News"), + Category(2, "Arts"), + Category(4, "Technology"), + Category(2, "TV & Film"), + ) + + val useCase = FilterableCategoriesUseCase( + categoryStore = categoriesStore + ) + + @Before + fun setUp() { + categoriesStore.setCategories(testCategories) + } + + @Test + fun whenNoSelectedCategory_onEmptySelectedCategoryInvoked() = runTest { + val filterableCategories = useCase(null).first() + assertEquals( + filterableCategories.categories[0], + filterableCategories.selectedCategory + ) + } + + @Test + fun whenSelectedCategory_correctFilterableCategoryIsSelected() = runTest { + val selectedCategory = testCategories[2] + val filterableCategories = useCase(selectedCategory.asExternalModel()).first() + assertEquals( + selectedCategory.asExternalModel(), + filterableCategories.selectedCategory + ) + } +} diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCaseTest.kt new file mode 100644 index 0000000000..6cd9f61385 --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/GetLatestFollowedEpisodesUseCaseTest.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.core.data.domain + +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.repository.TestEpisodeStore +import com.example.jetcaster.core.data.repository.TestPodcastStore +import java.time.OffsetDateTime +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class GetLatestFollowedEpisodesUseCaseTest { + + private val episodeStore = TestEpisodeStore() + private val podcastStore = TestPodcastStore() + + val useCase = GetLatestFollowedEpisodesUseCase( + episodeStore = episodeStore, + podcastStore = podcastStore + ) + + val testEpisodes = listOf( + Episode( + uri = "", + podcastUri = testPodcasts[0].podcast.uri, + title = "title1", + published = OffsetDateTime.MIN + ), + Episode( + uri = "", + podcastUri = testPodcasts[0].podcast.uri, + title = "title2", + published = OffsetDateTime.now() + ), + Episode( + uri = "", + podcastUri = testPodcasts[1].podcast.uri, + title = "title3", + published = OffsetDateTime.MAX + ) + ) + + @Test + fun whenNoFollowedPodcasts_emptyFlow() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + + assertTrue(result.first().isEmpty()) + } + + @Test + fun whenFollowedPodcasts_nonEmptyFlow() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri) + + assertTrue(result.first().isNotEmpty()) + } + + @Test + fun whenFollowedPodcasts_sortedByPublished() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri) + + result.first().zipWithNext { + ep1, ep2 -> + ep1.episode.published > ep2.episode.published + }.all { it } + } +} 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 new file mode 100644 index 0000000000..2f2d5a3b5b --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.domain + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.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 + +class PodcastCategoryFilterUseCaseTest { + + private val categoriesStore = TestCategoryStore() + private val testEpisodeToPodcast = listOf( + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 1", + published = OffsetDateTime.now() + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 1" + ) + ) + }, + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 2", + published = OffsetDateTime.now() + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 2" + ) + ) + }, + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 3", + published = OffsetDateTime.now() + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 3" + ) + ) + } + ) + private val testCategory = Category(1, "Technology") + + val useCase = PodcastCategoryFilterUseCase( + categoryStore = categoriesStore + ) + + @Test + fun whenCategoryNull_emptyFlow() = runTest { + val resultFlow = useCase(null) + + categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast) + categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts) + + val result = resultFlow.first() + assertTrue(result.topPodcasts.isEmpty()) + assertTrue(result.episodes.isEmpty()) + } + + @Test + fun whenCategoryNotNull_validFlow() = runTest { + val resultFlow = useCase(testCategory.asExternalModel()) + + categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast) + categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts) + + val result = resultFlow.first() + assertEquals( + testPodcasts.map { it.asExternalModel() }, + result.topPodcasts + ) + assertEquals( + testEpisodeToPodcast.map { it.asPodcastCategoryEpisode() }, + result.episodes + ) + } +} + +val testPodcasts = listOf( + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "nia", title = "Now in Android") + }, + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "adb", title = "Android Developers Backstage") + }, + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "techcrunch", title = "Techcrunch") + }, +) diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestCategoryStore.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestCategoryStore.kt new file mode 100644 index 0000000000..9b867f0f9e --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestCategoryStore.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** + * A [CategoryStore] used for testing. + */ +class TestCategoryStore : CategoryStore { + + private val categoryFlow = MutableStateFlow>(emptyList()) + private val podcastsInCategoryFlow = + MutableStateFlow>>(emptyMap()) + private val episodesFromPodcasts = + MutableStateFlow>>(emptyMap()) + + override fun categoriesSortedByPodcastCount(limit: Int): Flow> = + categoryFlow + + override fun podcastsInCategorySortedByPodcastCount( + categoryId: Long, + limit: Int + ): Flow> = podcastsInCategoryFlow.map { + it[categoryId] ?: emptyList() + } + + override fun episodesFromPodcastsInCategory( + categoryId: Long, + limit: Int + ): Flow> = episodesFromPodcasts.map { + it[categoryId] ?: emptyList() + } + + override suspend fun addCategory(category: Category): Long = -1 + + override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) {} + + /** + * Test-only API for setting the list of categories backed by this [TestCategoryStore]. + */ + fun setCategories(categories: List) { + categoryFlow.value = categories + } + + /** + * Test-only API for setting the list of podcasts in a category backed by this + * [TestCategoryStore]. + */ + fun setPodcastsInCategory(categoryId: Long, podcastsInCategory: List) { + podcastsInCategoryFlow.update { + it + Pair(categoryId, podcastsInCategory) + } + } + + /** + * Test-only API for setting the list of podcasts in a category backed by this + * [TestCategoryStore]. + */ + fun setEpisodesFromPodcast(categoryId: Long, podcastsInCategory: List) { + episodesFromPodcasts.update { + it + Pair(categoryId, podcastsInCategory) + } + } +} 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 new file mode 100644 index 0000000000..ec415eaa3c --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +class TestEpisodeStore : EpisodeStore { + + private val episodesFlow = MutableStateFlow>(listOf()) + override fun episodeWithUri(episodeUri: String): Flow = + 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> = + episodesFlow.map { episodes -> + episodes.filter { + it.podcastUri == podcastUri + }.map { e -> + EpisodeToPodcast().apply { + episode = e + } + } + } + + override fun episodesInPodcasts( + podcastUris: List, + limit: Int + ): Flow> = + episodesFlow.map { episodes -> + episodes.filter { + podcastUris.contains(it.podcastUri) + }.map { ep -> + EpisodeToPodcast().apply { + episode = ep + } + } + } + + override suspend fun addEpisodes(episodes: Collection) = + episodesFlow.update { + it + episodes + } + + override suspend fun isEmpty(): Boolean = + episodesFlow.first().isEmpty() +} 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 new file mode 100644 index 0000000000..be95e2951d --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestPodcastStore.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.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 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +class TestPodcastStore : PodcastStore { + + private val podcastFlow = MutableStateFlow>(listOf()) + private val followedPodcasts = mutableSetOf() + override fun podcastWithUri(uri: String): Flow = + podcastFlow.map { podcasts -> + 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 -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = followedPodcasts.contains(p.uri) + } + } + } + + override fun followedPodcastsSortedByLastEpisode(limit: Int): Flow> = + podcastFlow.map { podcasts -> + podcasts.filter { + followedPodcasts.contains(it.uri) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override fun searchPodcastByTitle( + keyword: String, + limit: Int + ): Flow> = + 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) { + unfollowPodcast(podcastUri) + } else { + followPodcast(podcastUri) + } + } + + override suspend fun followPodcast(podcastUri: String) { + followedPodcasts.add(podcastUri) + } + + override suspend fun unfollowPodcast(podcastUri: String) { + followedPodcasts.remove(podcastUri) + } + + override suspend fun addPodcast(podcast: Podcast) = + podcastFlow.update { it + podcast } + + override suspend fun isEmpty(): Boolean = + podcastFlow.first().isEmpty() +} 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..f33d90cafd --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MockEpisodePlayerTest { + + private val testDispatcher = StandardTestDispatcher() + private val mockEpisodePlayer = MockEpisodePlayer(testDispatcher) + private val testEpisodes = listOf( + PlayerEpisode( + uri = "uri1", + duration = Duration.ofSeconds(60) + ), + PlayerEpisode( + uri = "uri2", + duration = Duration.ofSeconds(60) + ), + PlayerEpisode( + uri = "uri3", + duration = Duration.ofSeconds(60) + ), + ) + + @Test + fun whenPlayDone_playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration + ) + mockEpisodePlayer.currentEpisode = currEpisode + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(duration.toMillis() + 1) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenNext_queueIsNotEmpty_autoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration + ) + mockEpisodePlayer.currentEpisode = currEpisode + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertTrue(mockEpisodePlayer.playerState.value.isPlaying) + } + + @Test + fun whenNext_queueIsEmpty_doesNothing() { + val episode = testEpisodes[0] + mockEpisodePlayer.currentEpisode = episode + mockEpisodePlayer.play() + + mockEpisodePlayer.next() + + assertEquals(episode, mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenAddToQueue_queueIsNotEmpty() = runTest(testDispatcher) { + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + advanceUntilIdle() + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size, queue.size) + testEpisodes.forEachIndexed { index, playerEpisode -> + assertEquals(playerEpisode, queue[index]) + } + } + + @Test + fun whenNext_queueIsNotEmpty_removeFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(100) + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) + } + + @Test + fun whenNext_queueIsNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(100) + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) + } + + @Test + fun whenPrevious_queueIsEmpty_resetSameEpisode() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = testEpisodes[0] + mockEpisodePlayer.play() + advanceTimeBy(1000L) + + mockEpisodePlayer.previous() + assertEquals(0, mockEpisodePlayer.playerState.value.timeElapsed.toMillis()) + assertEquals(testEpisodes[0], mockEpisodePlayer.currentEpisode) + } +} 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..7fdf99b1fd --- /dev/null +++ b/Jetcaster/designsystem/build.gradle.kts @@ -0,0 +1,51 @@ +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") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.text) + implementation(libs.coil.kt.compose) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) +} 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/component/ImageBackground.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt new file mode 100644 index 0000000000..4cb124dc65 --- /dev/null +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage + +@Composable +fun ImageBackgroundColorScrim( + url: String?, + color: Color, + modifier: Modifier = Modifier, +) { + ImageBackground( + url = url, + modifier = modifier, + overlay = { + drawRect(color) + } + ) +} + +@Composable +fun ImageBackgroundRadialGradientScrim( + url: String?, + colors: List, + 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/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..51ab6000bc --- /dev/null +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(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/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt similarity index 90% rename from Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt rename to Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt index 5242395c1c..4340443cbf 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt @@ -14,8 +14,8 @@ * limitations under the License. */ -package com.example.jetcaster.ui.theme +package com.example.jetcaster.designsystem.theme import androidx.compose.ui.unit.dp -val Keyline1 = 24.dp +val Keyline1 = 16.dp diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt similarity index 86% rename from Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt rename to Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt index 0e7b2e1148..41bbfefbb6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt @@ -14,14 +14,14 @@ * limitations under the License. */ -package com.example.jetcaster.ui.theme +package com.example.jetcaster.designsystem.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes +import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp val JetcasterShapes = Shapes( small = RoundedCornerShape(percent = 50), medium = RoundedCornerShape(size = 8.dp), - large = RoundedCornerShape(size = 0.dp) + large = RoundedCornerShape(size = 16.dp) ) diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt new file mode 100644 index 0000000000..b9d6eb171e --- /dev/null +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val JetcasterTypography = 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/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..bd9320cd6d --- /dev/null +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.example.jetcaster.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 4231a34163..9e2e8a8c9a 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -7,12 +7,14 @@ accompanist = "0.34.0" androidGradlePlugin = "8.3.1" 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.04.00" +androidx-compose-material3-adaptive = "1.0.0-alpha10" androidx-constraintlayout = "1.0.1" androidx-corektx = "1.13.0-rc01" 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 +23,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-beta01" -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.51" +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,14 +48,25 @@ maps-compose = "3.1.1" material = "1.11.0" # @keep minSdk = "21" -okhttp = "4.11.0" +okhttp = "4.12.0" robolectric = "4.12.1" rome = "1.18.0" -room = "2.6.0" +room = "2.6.1" secrets = "2.0.1" # @keep targetSdk = "33" version-catalog-update = "0.8.4" +playServicesWearable = "18.1.0" +composeMaterial = "1.2.1" +composeFoundation = "1.2.1" +coreSplashscreen = "1.0.1" +horologistComposeTools = "0.4.8" +horologist = "0.6.6" +roborazzi = "1.11.0" +androidx-wear-compose = "1.3.0" +wear-compose-ui-tooling = "1.3.0" +ui-test-manifest = "1.6.3" +ui-test-junit4 = "1.6.3" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -69,17 +84,20 @@ 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" } -androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +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-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"} 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" } @@ -91,7 +109,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" } @@ -110,7 +128,10 @@ 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" } +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" } @@ -119,6 +140,7 @@ googlemaps-maps = { module = "com.google.android.gms:play-services-maps", versio hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } @@ -130,10 +152,39 @@ okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" } rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } +play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" } +compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "composeMaterial" } +compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "composeFoundation" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +horologist-compose-tools = { group = "com.google.android.horologist", name = "horologist-compose-tools", version.ref = "horologistComposeTools" } +horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" } +horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } +horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" } +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" } +roborazzi-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } +androidx-splashscreen = "androidx.core:core-splashscreen:1.0.1" +wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } +wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "androidx-wear-compose" } +wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "androidx-wear-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "wear-compose-ui-tooling" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "ui-test-manifest" } +androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "ui-test-junit4" } +test-ext-junit = "androidx.test.ext:junit:1.1.5" +test-espresso-core = "androidx.test.espresso:espresso-core:3.5.1" +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } [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" } @@ -142,3 +193,4 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } diff --git a/Jetcaster/settings.gradle.kts b/Jetcaster/settings.gradle.kts index a7c272a185..a0016bc0dd 100644 --- a/Jetcaster/settings.gradle.kts +++ b/Jetcaster/settings.gradle.kts @@ -35,5 +35,5 @@ dependencyResolutionManagement { } } rootProject.name = "Jetcaster" -include(":app") - +include(":app", ":core", ":core:model", ":designsystem", ":tv-app", ":wear") +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 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..7448b4364f --- /dev/null +++ b/Jetcaster/tv-app/build.gradle.kts @@ -0,0 +1,100 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +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 { + isCoreLibraryDesugaringEnabled = true + 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.material.iconsExtended) + 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.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.coil.kt.compose) + + // 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")) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + coreLibraryDesugaring(libs.core.jdk.desugaring) +} diff --git a/Jetcaster/tv-app/proguard-rules.pro b/Jetcaster/tv-app/proguard-rules.pro new file mode 100644 index 0000000000..08718bb52d --- /dev/null +++ b/Jetcaster/tv-app/proguard-rules.pro @@ -0,0 +1,50 @@ +# 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 + +# 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 diff --git a/Jetcaster/tv-app/src/main/AndroidManifest.xml b/Jetcaster/tv-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3ab2d935a4 --- /dev/null +++ b/Jetcaster/tv-app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 0000000000..0d85c0b841 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt @@ -0,0 +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.tv + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@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 new file mode 100644 index 0000000000..6052428d09 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.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.tv + +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.graphics.RectangleShape +import androidx.compose.ui.tooling.preview.Preview +import androidx.tv.material3.ExperimentalTvMaterial3Api +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?) { + super.onCreate(savedInstanceState) + setContent { + JetcasterTheme { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape + ) { + JetcasterApp() + } + } + } + } +} + +@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") + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategoryList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategoryList.kt new file mode 100644 index 0000000000..34643d8e2a --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategoryList.kt @@ -0,0 +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.tv.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.Category + +@Immutable +data class CategoryList(val member: List) : List by member 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/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt new file mode 100644 index 0000000000..ad19b2c0d7 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -0,0 +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.tv.model + +import androidx.compose.runtime.Immutable +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/model/PodcastList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt new file mode 100644 index 0000000000..6c623e7fce --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.PodcastWithExtraInfo + +@Immutable +data class PodcastList( + 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 new file mode 100644 index 0000000000..529da265b4 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.NavigationDrawer +import androidx.tv.material3.NavigationDrawerItem +import androidx.tv.material3.Text +import com.example.jetcaster.tv.ui.discover.DiscoverScreen +import com.example.jetcaster.tv.ui.episode.EpisodeScreen +import com.example.jetcaster.tv.ui.library.LibraryScreen +import com.example.jetcaster.tv.ui.player.PlayerScreen +import com.example.jetcaster.tv.ui.podcast.PodcastScreen +import com.example.jetcaster.tv.ui.profile.ProfileScreen +import com.example.jetcaster.tv.ui.search.SearchScreen +import com.example.jetcaster.tv.ui.settings.SettingsScreen +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) { + Route(jetcasterAppState = jetcasterAppState) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun WithGlobalNavigation( + jetcasterAppState: JetcasterAppState, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + NavigationDrawer( + drawerContent = { + Column( + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.drawer.intoPaddingValues()) + ) { + + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToProfile, + leadingContent = { Icon(Icons.Default.Person, contentDescription = null) }, + ) { + Column { + Text(text = "Name") + Text(text = "Switch Account", style = MaterialTheme.typography.labelSmall) + } + } + Spacer(modifier = Modifier.weight(1f)) + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToSearch, + leadingContent = { Icon(Icons.Default.Search, contentDescription = null) } + ) { + Text(text = "Search") + } + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToDiscover, + leadingContent = { Icon(Icons.Default.Home, contentDescription = null) }, + ) { + Text(text = "Discover") + } + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToLibrary, + leadingContent = { Icon(Icons.Default.VideoLibrary, contentDescription = null) } + ) { + Text(text = "Library") + } + Spacer(modifier = Modifier.weight(1f)) + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToSettings, + leadingContent = { Icon(Icons.Default.Settings, contentDescription = null) } + ) { + Text(text = "Settings") + } + } + }, + content = content, + modifier = modifier + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun Route(jetcasterAppState: JetcasterAppState) { + NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { + composable(Screen.Discover.route) { + WithGlobalNavigation(jetcasterAppState = jetcasterAppState) { + DiscoverScreen( + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.uri) + }, + playEpisode = { + jetcasterAppState.playEpisode() + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize() + ) + } + } + + composable(Screen.Library.route) { + WithGlobalNavigation(jetcasterAppState = jetcasterAppState) { + LibraryScreen( + navigateToDiscover = jetcasterAppState::navigateToDiscover, + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.podcast.uri) + }, + playEpisode = { + jetcasterAppState.playEpisode() + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize() + ) + } + } + + composable(Screen.Search.route) { + SearchScreen( + onPodcastSelected = { + jetcasterAppState.showPodcastDetails(it.podcast.uri) + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize() + ) + } + + composable(Screen.Podcast.route) { + PodcastScreen( + backToHomeScreen = jetcasterAppState::navigateToDiscover, + playEpisode = { + jetcasterAppState.playEpisode() + }, + showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.uri) }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) + .fillMaxSize(), + ) + } + + composable(Screen.Episode.route) { + EpisodeScreen( + playEpisode = { + jetcasterAppState.playEpisode() + }, + backToHome = jetcasterAppState::backToHome, + ) + } + + composable(Screen.Player.route) { + PlayerScreen( + backToHome = jetcasterAppState::backToHome, + modifier = Modifier.fillMaxSize(), + showDetails = jetcasterAppState::showEpisodeDetails, + ) + } + + composable(Screen.Profile.route) { + ProfileScreen( + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + ) + } + } +} 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 new file mode 100644 index 0000000000..74077a81e6 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.example.jetcaster.core.model.PlayerEpisode + +class JetcasterAppState( + val navHostController: NavHostController +) { + fun navigateToDiscover() { + navHostController.navigate(Screen.Discover.route) + } + + fun navigateToLibrary() { + navHostController.navigate(Screen.Library.route) + } + + fun navigateToProfile() { + navHostController.navigate(Screen.Profile.route) + } + + fun navigateToSearch() { + navHostController.navigate(Screen.Search.route) + } + + fun navigateToSettings() { + navHostController.navigate(Screen.Settings.route) + } + + fun showPodcastDetails(podcastUri: String) { + val encodedUrL = Uri.encode(podcastUri) + val screen = Screen.Podcast(encodedUrL) + navHostController.navigate(screen.route) + } + + fun showEpisodeDetails(episodeUri: String) { + val encodeUrl = Uri.encode(episodeUri) + val screen = Screen.Episode(encodeUrl) + navHostController.navigate(screen.route) + } + + fun showEpisodeDetails(playerEpisode: PlayerEpisode) { + showEpisodeDetails(playerEpisode.uri) + } + + fun playEpisode() { + navHostController.navigate(Screen.Player.route) + } + + fun backToHome() { + navHostController.popBackStack() + navigateToDiscover() + } +} + +@Composable +fun rememberJetcasterAppState( + navHostController: NavHostController = rememberNavController() +) = + remember(navHostController) { + JetcasterAppState(navHostController) + } + +sealed interface Screen { + val route: String + + data object Discover : Screen { + override val route = "/discover" + } + + data object Library : Screen { + override val route = "library" + } + + data object Search : Screen { + override val route = "search" + } + + data object Profile : Screen { + override val route = "profile" + } + + data object Settings : Screen { + override val route: String = "settings" + } + + data class Podcast(private val podcastUri: String) : Screen { + override val route = "$ROOT/$podcastUri" + + companion object : Screen { + private const val ROOT = "podcast" + const val PARAMETER_NAME = "podcastUri" + override val route = "$ROOT/{$PARAMETER_NAME}" + } + } + + data class Episode(private val episodeUri: String) : Screen { + + override val route: String = "$ROOT/$episodeUri" + companion object : Screen { + private const val ROOT = "episode" + const val PARAMETER_NAME = "episodeUri" + override val route = "$ROOT/{$PARAMETER_NAME}" + } + } + + data object Player : Screen { + override val route = "player" + } +} 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..752cbdf3f7 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.ImageBackgroundRadialGradientScrim + +@Composable +internal fun Background( + podcast: Podcast, + modifier: Modifier = Modifier, +) = Background(imageUrl = podcast.imageUrl, modifier) + +@Composable +internal fun Background( + episode: PlayerEpisode, + modifier: Modifier = Modifier, +) = Background(imageUrl = episode.podcastImageUrl, modifier) + +@Composable +internal fun Background( + imageUrl: String?, + modifier: Modifier = Modifier, +) { + ImageBackgroundRadialGradientScrim( + url = imageUrl, + colors = listOf(Color.Black, Color.Transparent), + modifier = modifier, + ) +} + +@Composable +internal fun BackgroundContainer( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit +) { + Box(modifier = modifier, contentAlignment = contentAlignment) { + Background(episode = playerEpisode, 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 new file mode 100644 index 0000000000..68c6cf685d --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ButtonScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.example.jetcaster.tv.R + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun PlayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + scale: ButtonScale = ButtonDefaults.scale(), +) = + ButtonWithIcon( + icon = Icons.Outlined.PlayArrow, + label = stringResource(R.string.label_play), + onClick = onClick, + modifier = modifier, + scale = scale + ) + +@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 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 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/ButtonWithIcon.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt new file mode 100644 index 0000000000..b6ad6723da --- /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, + modifier: Modifier = Modifier, + scale: ButtonScale = ButtonDefaults.scale(), +) { + 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 new file mode 100644 index 0000000000..1a3a03f0ff --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +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.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 +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.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +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 +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun Catalog( + podcastList: PodcastList, + latestEpisodeList: EpisodeList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + state: TvLazyListState = rememberTvLazyListState(), + header: (@Composable () -> Unit)? = null, +) { + TvLazyColumn( + modifier = modifier, + contentPadding = JetcasterAppDefaults.overScanMargin.catalog.intoPaddingValues(), + verticalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gap.section), + state = state, + ) { + if (header != null) { + item { header() } + } + item { + PodcastSection( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + title = stringResource(R.string.label_podcast) + ) + } + item { + LatestEpisodeSection( + episodeList = latestEpisodeList, + onEpisodeSelected = onEpisodeSelected, + title = stringResource(R.string.label_latest_episode) + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@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, + modifier = Modifier.focusRestorer() + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun LatestEpisodeSection( + episodeList: EpisodeList, + onEpisodeSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + title: String? = null +) { + Section( + modifier = modifier, + title = title + ) { + EpisodeRow( + playerEpisodeList = episodeList, + onSelected = onEpisodeSelected, + modifier = Modifier.focusRestorer() + ) + } +} + +@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.gap.podcastRow), +) { + 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 +internal 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, + ) +} 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..0976f08218 --- /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.database.model.toPlayerEpisode +import com.example.jetcaster.core.model.PlayerEpisode +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/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/EpisodeDetails.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt new file mode 100644 index 0000000000..6fb101fc71 --- /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.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/EpisodeRow.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt new file mode 100644 index 0000000000..8690cc7b71 --- /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.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/component/ErrorState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt new file mode 100644 index 0000000000..f70e6b3557 --- /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.theme.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.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/Loading.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt new file mode 100644 index 0000000000..15b6c386c1 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.Alignment +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 + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun Loading( + modifier: Modifier = Modifier, + message: String = stringResource(id = R.string.message_loading), + contentAlignment: Alignment = Alignment.Center, + style: TextStyle = MaterialTheme.typography.displayMedium +) { + Box( + modifier = modifier, + contentAlignment = contentAlignment + ) { + Text(text = message, style = style) + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt new file mode 100644 index 0000000000..5ceb504939 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun NotAvailableFeature( + modifier: Modifier = Modifier, + message: String = stringResource(id = R.string.message_not_available_feature) +) { + Text(message, modifier = modifier) +} 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 new file mode 100644 index 0000000000..88b37b49d6 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.core.model.PlayerEpisode +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 +) = + 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 = url, + 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/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/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt new file mode 100644 index 0000000000..49ea74414b --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.discover + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +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 +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.core.model.PlayerEpisode +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.component.Catalog +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun DiscoverScreen( + showPodcastDetails: (Podcast) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + discoverScreenViewModel: DiscoverScreenViewModel = hiltViewModel() +) { + val uiState by discoverScreenViewModel.uiState.collectAsState() + + when (val s = uiState) { + DiscoverScreenUiState.Loading -> { + Loading( + modifier = Modifier + .fillMaxSize() + .then(modifier) + ) + } + + is DiscoverScreenUiState.Ready -> { + CatalogWithCategorySelection( + categoryList = s.categoryList, + podcastList = s.podcastList, + selectedCategory = s.selectedCategory, + latestEpisodeList = s.latestEpisodeList, + onPodcastSelected = { showPodcastDetails(it.podcast) }, + onCategorySelected = discoverScreenViewModel::selectCategory, + onEpisodeSelected = { + discoverScreenViewModel.play(it) + playEpisode(it) + }, + modifier = Modifier + .fillMaxSize() + .then(modifier) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun CatalogWithCategorySelection( + categoryList: CategoryList, + podcastList: PodcastList, + selectedCategory: Category, + latestEpisodeList: EpisodeList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, + onCategorySelected: (Category) -> Unit, + modifier: Modifier = Modifier, + state: TvLazyListState = rememberTvLazyListState(), +) { + val (focusRequester, selectedTab) = remember { + FocusRequester.createRefs() + } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + val selectedTabIndex = categoryList.indexOf(selectedCategory) + + Catalog( + podcastList = podcastList, + latestEpisodeList = latestEpisodeList, + onPodcastSelected = { + focusRequester.saveFocusedChild() + onPodcastSelected(it) + }, + onEpisodeSelected = { + focusRequester.saveFocusedChild() + onEpisodeSelected(it) + }, + modifier = modifier + .focusRequester(focusRequester) + .focusRestorer(), + state = state, + ) { + + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier.focusProperties { + enter = { + selectedTab + } + } + ) { + categoryList.forEachIndexed { index, category -> + val tabModifier = if (selectedTabIndex == index) { + Modifier.focusRequester(selectedTab) + } else { + Modifier + } + + Tab( + selected = index == selectedTabIndex, + onFocus = { + onCategorySelected(category) + }, + modifier = tabModifier, + ) { + Text( + text = category.name, + modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) + ) + } + } + } + } +} 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 new file mode 100644 index 0000000000..44b638aad4 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.discover + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.Category +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 +import com.example.jetcaster.tv.model.PodcastList +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class DiscoverScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val categoryStore: CategoryStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val _selectedCategory = MutableStateFlow(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( + categoryListFlow, + _selectedCategory + ) { categoryList, category -> + category ?: categoryList.firstOrNull() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val podcastInSelectedCategory = selectedCategoryFlow.flatMapLatest { + if (it != null) { + categoryStore.podcastsInCategorySortedByPodcastCount(it.id) + } else { + flowOf(emptyList()) + } + }.map { + PodcastList(it) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val latestEpisodeFlow = selectedCategoryFlow.flatMapLatest { + if (it != null) { + categoryStore.episodesFromPodcastsInCategory(it.id, 20) + } else { + flowOf(emptyList()) + } + }.map { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) + } + + val uiState = combine( + categoryListFlow, + selectedCategoryFlow, + podcastInSelectedCategory, + latestEpisodeFlow, + ) { categoryList, category, podcastList, latestEpisodes -> + if (category != null) { + DiscoverScreenUiState.Ready( + CategoryList(categoryList), + category, + podcastList, + latestEpisodes + ) + } else { + DiscoverScreenUiState.Loading + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + DiscoverScreenUiState.Loading + ) + + init { + refresh() + } + + fun selectCategory(category: Category) { + _selectedCategory.value = category + } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + private fun refresh() { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +sealed interface DiscoverScreenUiState { + data object Loading : DiscoverScreenUiState + data class Ready( + val categoryList: CategoryList, + val selectedCategory: Category, + val podcastList: PodcastList, + val latestEpisodeList: EpisodeList, + ) : DiscoverScreenUiState +} 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..aaea796f6e --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.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.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.hilt.navigation.compose.hiltViewModel +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.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 +import com.example.jetcaster.tv.ui.component.ErrorState +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.PlayButton +import com.example.jetcaster.tv.ui.component.Thumbnail +import com.example.jetcaster.tv.ui.component.TwoColumn +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun EpisodeScreen( + playEpisode: () -> Unit, + backToHome: () -> Unit, + modifier: Modifier = Modifier, + episodeScreenViewModel: EpisodeScreenViewModel = hiltViewModel() +) { + + val uiState by episodeScreenViewModel.uiStateFlow.collectAsState() + + when (val s = uiState) { + EpisodeScreenUiState.Loading -> Loading(modifier = modifier) + EpisodeScreenUiState.Error -> ErrorState(backToHome = backToHome, modifier = modifier) + is EpisodeScreenUiState.Ready -> EpisodeDetailsWithBackground( + episodeToPodcast = s.episodeToPodcast, + playEpisode = { + episodeScreenViewModel.play(it) + playEpisode() + }, + addPlayList = episodeScreenViewModel::addPlayList + ) + } +} + +@Composable +private fun EpisodeDetailsWithBackground( + episodeToPodcast: EpisodeToPodcast, + playEpisode: (PlayerEpisode) -> Unit, + addPlayList: (PlayerEpisode) -> 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: (PlayerEpisode) -> Unit, + addPlayList: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, +) { + 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, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeInfo( + episode: Episode, + playEpisode: () -> Unit, + addPlayList: () -> 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, addPlayList = addPlayList) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun Controls( + playEpisode: () -> Unit, + addPlayList: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + PlayButton(onClick = playEpisode) + EnqueueButton(onClick = addPlayList) + } +} 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..9974d49952 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.episode + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +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 +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class EpisodeScreenViewModel @Inject constructor( + handle: SavedStateHandle, + podcastsRepository: PodcastsRepository, + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val episodeUriFlow = handle.getStateFlow(Screen.Episode.PARAMETER_NAME, null) + + @OptIn(ExperimentalCoroutinesApi::class) + private val episodeToPodcastFlow = episodeUriFlow.flatMapLatest { + if (it != null) { + episodeStore.episodeAndPodcastWithUri(it) + } else { + flowOf(null) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + val uiStateFlow = episodeToPodcastFlow.map { + if (it != null) { + EpisodeScreenUiState.Ready(it) + } else { + EpisodeScreenUiState.Error + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + EpisodeScreenUiState.Loading + ) + + fun addPlayList(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +sealed interface EpisodeScreenUiState { + data object Loading : EpisodeScreenUiState + data object Error : EpisodeScreenUiState + data class Ready(val 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 new file mode 100644 index 0000000000..84cc659c69 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.library + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.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.model.PlayerEpisode +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.component.Catalog +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun LibraryScreen( + modifier: Modifier = Modifier, + navigateToDiscover: () -> Unit, + showPodcastDetails: (PodcastWithExtraInfo) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + libraryScreenViewModel: LibraryScreenViewModel = hiltViewModel() +) { + val uiState by libraryScreenViewModel.uiState.collectAsState() + when (val s = uiState) { + LibraryScreenUiState.Loading -> Loading(modifier = modifier) + LibraryScreenUiState.NoSubscribedPodcast -> { + NavigateToDiscover(onNavigationRequested = navigateToDiscover, modifier = modifier) + } + + is LibraryScreenUiState.Ready -> Library( + podcastList = s.subscribedPodcastList, + episodeList = s.latestEpisodeList, + showPodcastDetails = showPodcastDetails, + onEpisodeSelected = { + libraryScreenViewModel.playEpisode(it) + playEpisode(it) + }, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Library( + podcastList: PodcastList, + episodeList: EpisodeList, + showPodcastDetails: (PodcastWithExtraInfo) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Catalog( + podcastList = podcastList, + latestEpisodeList = episodeList, + onPodcastSelected = showPodcastDetails, + onEpisodeSelected = onEpisodeSelected, + modifier = modifier + .focusRequester(focusRequester) + .focusRestorer() + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun NavigateToDiscover( + onNavigationRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column { + Text( + text = stringResource(id = R.string.display_no_subscribed_podcast), + style = MaterialTheme.typography.displayMedium + ) + Text(text = stringResource(id = R.string.message_no_subscribed_podcast)) + Button( + onClick = onNavigationRequested, + modifier = Modifier + .padding(top = JetcasterAppDefaults.gap.podcastRow) + .focusRequester(focusRequester) + ) { + Text(text = stringResource(id = R.string.label_navigate_to_discover)) + } + } + } +} 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..488b5c2da8 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.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 +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class LibraryScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val episodeStore: EpisodeStore, + podcastStore: PodcastStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode().map { + 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 { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) + } + + val uiState = + combine(followingPodcastListFlow, latestEpisodeListFlow) { podcastList, episodeList -> + if (podcastList.isEmpty()) { + LibraryScreenUiState.NoSubscribedPodcast + } else { + LibraryScreenUiState.Ready(podcastList, episodeList) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + LibraryScreenUiState.Loading + ) + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } + + fun playEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } +} + +sealed interface LibraryScreenUiState { + data object Loading : LibraryScreenUiState + data object NoSubscribedPodcast : LibraryScreenUiState + data class Ready( + val subscribedPodcastList: PodcastList, + val latestEpisodeList: EpisodeList, + ) : LibraryScreenUiState +} 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..dd3047d44d --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -0,0 +1,464 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +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.EpisodeList +import com.example.jetcaster.tv.ui.component.BackgroundContainer +import com.example.jetcaster.tv.ui.component.EnqueueButton +import com.example.jetcaster.tv.ui.component.EpisodeDetails +import com.example.jetcaster.tv.ui.component.EpisodeRow +import com.example.jetcaster.tv.ui.component.InfoButton +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.NextButton +import com.example.jetcaster.tv.ui.component.PlayPauseButton +import com.example.jetcaster.tv.ui.component.PreviousButton +import com.example.jetcaster.tv.ui.component.RewindButton +import com.example.jetcaster.tv.ui.component.Seekbar +import com.example.jetcaster.tv.ui.component.SkipButton +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults +import java.time.Duration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun PlayerScreen( + backToHome: () -> Unit, + showDetails: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + playScreenViewModel: PlayerScreenViewModel = hiltViewModel() +) { + val uiState by playScreenViewModel.uiStateFlow.collectAsStateWithLifecycle() + + when (val s = uiState) { + PlayerScreenUiState.Loading -> Loading(modifier) + PlayerScreenUiState.NoEpisodeInQueue -> { + NoEpisodeInQueue(backToHome = backToHome, modifier = modifier) + } + + is PlayerScreenUiState.Ready -> { + Player( + episodePlayerState = s.playerState, + play = playScreenViewModel::play, + pause = playScreenViewModel::pause, + previous = playScreenViewModel::previous, + next = playScreenViewModel::next, + skip = playScreenViewModel::skip, + rewind = playScreenViewModel::rewind, + enqueue = playScreenViewModel::enqueue, + playEpisode = playScreenViewModel::play, + showDetails = showDetails, + ) + } + } +} + +@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 = EpisodeList(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: EpisodeList, + isPlaying: Boolean, + timeElapsed: Duration, + play: () -> Unit, + pause: () -> Unit, + previous: () -> Unit, + next: () -> Unit, + skip: () -> Unit, + rewind: () -> Unit, + enqueue: (PlayerEpisode) -> Unit, + showDetails: (PlayerEpisode) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier +) { + BackgroundContainer( + playerEpisode = playerEpisode, + modifier = modifier, + contentAlignment = Alignment.Center + ) { + + EpisodePlayer( + playerEpisode = playerEpisode, + isPlaying = isPlaying, + timeElapsed = timeElapsed, + play = play, + pause = pause, + previous = previous, + next = next, + skip = skip, + rewind = rewind, + enqueue = enqueue, + showDetails = showDetails, + 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)) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PlayerQueueOverlay( + playerEpisodeList: EpisodeList, + onSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + contentPadding: PaddingValues = PaddingValues(), + contentAlignment: Alignment = Alignment.BottomStart, + scrim: DrawScope.() -> Unit = { + val brush = Brush.verticalGradient( + listOf(Color.Transparent, Color.Black), + ) + drawRect(brush, blendMode = BlendMode.Multiply) + }, + offset: DpOffset = DpOffset.Zero, + 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, + ) { + EpisodeRow( + 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..f41330b5f8 --- /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.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/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt new file mode 100644 index 0000000000..ddc0ce32c5 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -0,0 +1,382 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.podcast + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.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.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.data.database.model.Podcast +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 +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: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + podcastScreenViewModel: PodcastScreenViewModel = hiltViewModel(), +) { + 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 -> PodcastDetailsWithBackground( + podcast = s.podcast, + episodeList = s.episodeList, + isSubscribed = s.isSubscribed, + subscribe = podcastScreenViewModel::subscribe, + unsubscribe = podcastScreenViewModel::unsubscribe, + playEpisode = { + podcastScreenViewModel.play(it) + playEpisode(it) + }, + enqueue = podcastScreenViewModel::enqueue, + showEpisodeDetails = showEpisodeDetails, + ) + } +} + +@Composable +private fun PodcastDetailsWithBackground( + podcast: Podcast, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + Box(modifier = modifier) { + Background(podcast = podcast) + PodcastDetails( + podcast = podcast, + episodeList = episodeList, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + playEpisode = playEpisode, + focusRequester = focusRequester, + showEpisodeDetails = showEpisodeDetails, + enqueue = enqueue, + modifier = Modifier + .fillMaxSize() + .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PodcastDetails( + podcast: Podcast, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + TwoColumn( + modifier = modifier, + horizontalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn), + first = { + PodcastInfo( + 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() + } +} + +@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) { + Thumbnail(podcast = podcast) + 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.gap.podcastRow) + ) + } +} + +@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, + playEpisode: (PlayerEpisode) -> Unit, + showDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier +) { + TvLazyColumn( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), + modifier = modifier + ) { + items(episodeList) { + EpisodeListItem( + playerEpisode = it, + onEpisodeSelected = { playEpisode(it) }, + onInfoClicked = { showDetails(it) }, + onEnqueueClicked = { enqueue(it) }, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeListItem( + playerEpisode: PlayerEpisode, + onEpisodeSelected: () -> Unit, + onInfoClicked: () -> Unit, + onEnqueueClicked: () -> Unit, + modifier: Modifier = Modifier, + borderWidth: Dp = 2.dp, + cornerRadius: Dp = 12.dp, +) { + var hasFocus by remember { + mutableStateOf(false) + } + val shape = RoundedCornerShape(cornerRadius) + + val backgroundColor = if (hasFocus) { + MaterialTheme.colorScheme.surface + } else { + Color.Transparent + } + + val borderColor = if (hasFocus) { + MaterialTheme.colorScheme.border + } else { + Color.Transparent + } + val elevation = if (hasFocus) { + 10.dp + } else { + 0.dp + } + + EpisodeListItemContentLayer( + playerEpisode = playerEpisode, + onEpisodeSelected = onEpisodeSelected, + onInfoClicked = onInfoClicked, + onEnqueueClicked = onEnqueueClicked, + modifier = modifier + .clip(shape) + .onFocusChanged { + hasFocus = it.hasFocus + } + .border(borderWidth, borderColor, shape) + .background(backgroundColor) + .shadow(elevation, shape) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 16.dp) + ) +} + +@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 + ) { + + 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(playerEpisode: PlayerEpisode, modifier: Modifier = Modifier) { + Text( + 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 new file mode 100644 index 0000000000..ace9275b0c --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.podcast + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.Podcast +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 +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class 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) + + private val podcastFlow = if (podcastUri != null) { + podcastStore.podcastWithUri(podcastUri) + } else { + flowOf(null) + }.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 { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) + } + + private val subscribedPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode() + + val uiStateFlow = combine( + podcastFlow, + episodeListFlow, + subscribedPodcastListFlow + ) { podcast, episodeList, subscribedPodcastList -> + if (podcast != null) { + val isSubscribed = subscribedPodcastList.any { it.podcast.uri == podcastUri } + PodcastScreenUiState.Ready(podcast, 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) + } + } + } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + fun enqueue(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } +} + +sealed interface PodcastScreenUiState { + data object Loading : PodcastScreenUiState + data object Error : PodcastScreenUiState + data class Ready( + val podcast: Podcast, + val episodeList: EpisodeList, + val isSubscribed: Boolean + ) : PodcastScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000000..b9cdd39734 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.profile + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun ProfileScreen(modifier: Modifier = Modifier) { + NotAvailableFeature(modifier = modifier) +} 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 new file mode 100644 index 0000000000..813cd19597 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -0,0 +1,276 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.search + +import androidx.compose.foundation.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.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.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( + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + searchScreenViewModel: SearchScreenViewModel = hiltViewModel() +) { + val uiState by searchScreenViewModel.uiStateFlow.collectAsState() + + when (val s = uiState) { + SearchScreenUiState.Loading -> Loading(modifier = modifier) + is SearchScreenUiState.Ready -> Ready( + keyword = s.keyword, + categorySelectionList = s.categorySelectionList, + onKeywordInput = searchScreenViewModel::setKeyword, + onCategorySelected = searchScreenViewModel::addCategoryToSelectedCategoryList, + onCategoryUnselected = searchScreenViewModel::removeCategoryFromSelectedCategoryList, + modifier = modifier + ) + + is SearchScreenUiState.HasResult -> HasResult( + keyword = s.keyword, + categorySelectionList = s.categorySelectionList, + podcastList = s.result, + onKeywordInput = searchScreenViewModel::setKeyword, + onCategorySelected = searchScreenViewModel::addCategoryToSelectedCategoryList, + onCategoryUnselected = searchScreenViewModel::removeCategoryFromSelectedCategoryList, + onPodcastSelected = onPodcastSelected, + modifier = modifier, + ) + } +} + +@Composable +private fun Ready( + keyword: String, + categorySelectionList: CategorySelectionList, + onKeywordInput: (String) -> Unit, + onCategorySelected: (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.gap.item), + 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) + BasicTextField( + value = keyword, + onValueChange = onKeywordInput, + textStyle = textStyle, + cursorBrush = cursorBrush, + modifier = modifier, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(percent = 50) + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + 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.gap.chip), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip), + ) { + 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.gap.podcastRow), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), + 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..24863951ae --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.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 dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class SearchScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val podcastStore: PodcastStore, + categoryStore: CategoryStore, +) : ViewModel() { + + private val keywordFlow = MutableStateFlow("") + private val selectedCategoryListFlow = MutableStateFlow>(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/settings/SettingsScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000000..53bf32f50c --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun SettingsScreen( + modifier: Modifier = Modifier +) { + NotAvailableFeature(modifier = modifier) +} 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..dfaa8d98ff --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.backgroundLight +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorContainerLight +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.errorLight +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight +import com.example.jetcaster.designsystem.theme.inversePrimaryDark +import com.example.jetcaster.designsystem.theme.inversePrimaryLight +import com.example.jetcaster.designsystem.theme.inverseSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseSurfaceLight +import com.example.jetcaster.designsystem.theme.onBackgroundDark +import com.example.jetcaster.designsystem.theme.onBackgroundLight +import com.example.jetcaster.designsystem.theme.onErrorContainerDark +import com.example.jetcaster.designsystem.theme.onErrorContainerLight +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onErrorLight +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onPrimaryLight +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryLight +import com.example.jetcaster.designsystem.theme.onSurfaceDark +import com.example.jetcaster.designsystem.theme.onSurfaceLight +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.onTertiaryLight +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineLight +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.outlineVariantLight +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryContainerLight +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.primaryLight +import com.example.jetcaster.designsystem.theme.scrimDark +import com.example.jetcaster.designsystem.theme.scrimLight +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryContainerLight +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.secondaryLight +import com.example.jetcaster.designsystem.theme.surfaceDark +import com.example.jetcaster.designsystem.theme.surfaceLight +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantLight +import com.example.jetcaster.designsystem.theme.tertiaryContainerDark +import com.example.jetcaster.designsystem.theme.tertiaryContainerLight +import com.example.jetcaster.designsystem.theme.tertiaryDark +import com.example.jetcaster.designsystem.theme.tertiaryLight + +@OptIn(ExperimentalTvMaterial3Api::class) +val colorSchemeForDarkMode = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + border = outlineDark, + borderVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, +) + +// Todo: specify surfaceTint +@OptIn(ExperimentalTvMaterial3Api::class) +val colorSchemeForLightMode = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + border = outlineLight, + borderVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, +) 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..9e9f3edfc9 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +internal data object JetcasterAppDefaults { + val overScanMargin = OverScanMarginSettings() + val gap = GapSettings() + val cardWidth = CardWidth() + val padding = PaddingSettings() + val thumbnailSize = ThumbnailSize() + val iconButtonSize: IconButtonSize = IconButtonSize() +} + +internal data class OverScanMarginSettings( + val default: OverScanMargin = OverScanMargin(), + val catalog: OverScanMargin = OverScanMargin(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 podcast: OverScanMargin = OverScanMargin( + top = 40.dp, + bottom = 40.dp, + start = 80.dp, + end = 80.dp + ), + val player: OverScanMargin = OverScanMargin( + top = 40.dp, + bottom = 40.dp, + start = 80.dp, + end = 80.dp + ), +) + +internal data class OverScanMargin( + val top: Dp = 24.dp, + val bottom: Dp = 24.dp, + val start: Dp = 48.dp, + val end: Dp = 48.dp, +) { + fun intoPaddingValues(): PaddingValues { + return PaddingValues(start, top, end, bottom) + } +} + +internal data class CardWidth( + val large: Dp = 268.dp, + val medium: Dp = 196.dp, + val small: Dp = 124.dp +) + +internal data class ThumbnailSize( + val 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 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/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..a1487570ba --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun JetcasterTheme( + isInDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (isInDarkTheme) { + colorSchemeForDarkMode + } else { + colorSchemeForLightMode + } + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} 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..cb7bd282bc --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Typography +import com.example.jetcaster.designsystem.theme.Montserrat + +// Set of Material typography styles to start with +@OptIn(ExperimentalTvMaterial3Api::class) +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + ), + displayMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 42.sp, + lineHeight = 52.sp, + ), + displaySmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + ), + headlineLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + ), + headlineMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineSmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + titleSmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + labelSmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + bodyLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + bodySmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ) +) 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 0000000000..c209e78ecd Binary files /dev/null and b/Jetcaster/tv-app/src/main/res/mipmap-hdpi/ic_launcher.webp differ 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 0000000000..4f0f1d64e5 Binary files /dev/null and b/Jetcaster/tv-app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Jetcaster/tv-app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Jetcaster/tv-app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/Jetcaster/tv-app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ 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 0000000000..28d4b77f9f Binary files /dev/null and b/Jetcaster/tv-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Jetcaster/tv-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Jetcaster/tv-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/Jetcaster/tv-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..865eeff288 --- /dev/null +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -0,0 +1,60 @@ + + + + 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! + Something wrong happened + No episode in the queue + Discover the Podcast you want to listen to + Podcast + Latest Episodes + Subscribe + Unsubscribe + Info + Play + Pause + Skip 10 seconds + Rewind 10 seconds + Play the next episode + Play the previous episode + Listen + Podcasts + Episodes + Latest Episodes + Discover the podcasts + Back to Home + Search podcasts by keyword + Add to playlist + + 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 + %1$s • %2$s + %1$02d:%2$02d + \ 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..295b149829 --- /dev/null +++ b/Jetcaster/tv-app/src/main/res/values/themes.xml @@ -0,0 +1,19 @@ + + + + + + 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..6fea6274fd --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -0,0 +1,74 @@ + + + + Jetcaster + + Connection error + Unable to fetch podcasts feeds.\nCheck your internet connection and try again. + Retry + + 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 + 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 + 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 + 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 @@ + + + + + + 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 ac0d281975..953351d1ce 100644 Binary files a/readme/screenshots/Jetcaster.png and b/readme/screenshots/Jetcaster.png differ