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
-
+
## 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 🎨.
+
### 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