diff --git a/Jetcaster/.gitignore b/Jetcaster/.gitignore
new file mode 100644
index 0000000000..b5c3f152f9
--- /dev/null
+++ b/Jetcaster/.gitignore
@@ -0,0 +1,23 @@
+# Gradle
+.gradle
+build/
+
+captures
+
+/local.properties
+
+# IntelliJ .idea folder
+.idea/workspace.xml
+.idea/libraries
+.idea/caches
+.idea/navEditor.xml
+.idea/tasks.xml
+.idea/modules.xml
+.idea/compiler.xml
+/.idea/jarRepositories.xml
+gradle.xml
+*.iml
+
+# General
+.DS_Store
+.externalNativeBuild
diff --git a/Jetcaster/ASSETS_LICENSE b/Jetcaster/ASSETS_LICENSE
new file mode 100644
index 0000000000..e7fc95866c
--- /dev/null
+++ b/Jetcaster/ASSETS_LICENSE
@@ -0,0 +1,88 @@
+All font files are licensed under the SIL OPEN FONT LICENSE license. All other files are licensed under the Apache 2 license.
+
+
+SIL OPEN FONT LICENSE
+Version 1.1 - 26 February 2007
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting — in part or in whole — any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/Jetcaster/app/build.gradle b/Jetcaster/app/build.gradle
new file mode 100644
index 0000000000..a86a52fa81
--- /dev/null
+++ b/Jetcaster/app/build.gradle
@@ -0,0 +1,112 @@
+/*
+ * 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.
+ */
+
+import com.example.jetcaster.buildsrc.Libs
+
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+}
+
+kapt {
+ correctErrorTypes = true
+ useBuildCache = true
+}
+
+android {
+ compileSdkVersion 30
+
+ defaultConfig {
+ applicationId 'com.example.jetcaster'
+ minSdkVersion 21
+ targetSdkVersion 30
+ versionCode 1
+ versionName '1.0'
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
+
+ javaCompileOptions {
+ annotationProcessorOptions {
+ arguments = [
+ "room.incremental" : "true",
+ "room.expandProjection": "true"
+ ]
+ }
+ }
+ }
+
+ packagingOptions {
+ // The Rome library JARs embed some internal utils libraries in nested JARs.
+ // We don't need them so we exclude them in the final package.
+ exclude "/*.jar"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ compileOptions {
+ // Flag to enable support for the new language APIs
+ coreLibraryDesugaringEnabled true
+
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ buildFeatures {
+ compose true
+ }
+
+ composeOptions {
+ kotlinCompilerVersion Libs.AndroidX.Compose.kotlinCompilerVersion
+ kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version
+ }
+}
+
+dependencies {
+ implementation Libs.Kotlin.stdlib
+ implementation Libs.Coroutines.android
+
+ implementation Libs.AndroidX.coreKtx
+ implementation Libs.AndroidX.appcompat
+ implementation Libs.AndroidX.palette
+ implementation Libs.material
+
+ implementation Libs.AndroidX.Lifecycle.viewmodel
+
+ implementation Libs.AndroidX.Compose.core
+ implementation Libs.AndroidX.Compose.layout
+ implementation Libs.AndroidX.Compose.material
+ implementation Libs.AndroidX.Compose.materialIconsExtended
+ implementation Libs.AndroidX.Compose.tooling
+
+ implementation Libs.Accompanist.coil
+
+ implementation Libs.OkHttp.okhttp
+ implementation Libs.OkHttp.logging
+
+ implementation Libs.Rome.rome
+ implementation Libs.Rome.modules
+
+ implementation Libs.AndroidX.Room.runtime
+ implementation Libs.AndroidX.Room.ktx
+ kapt Libs.AndroidX.Room.compiler
+
+ coreLibraryDesugaring Libs.jdkDesugar
+}
diff --git a/Jetcaster/app/proguard-rules.pro b/Jetcaster/app/proguard-rules.pro
new file mode 100644
index 0000000000..481bb43481
--- /dev/null
+++ b/Jetcaster/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/Jetcaster/app/src/main/AndroidManifest.xml b/Jetcaster/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..2cf77d717f
--- /dev/null
+++ b/Jetcaster/app/src/main/AndroidManifest.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt
new file mode 100644
index 0000000000..d07eccf869
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt
@@ -0,0 +1,115 @@
+/*
+ * 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 kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import okhttp3.Cache
+import okhttp3.OkHttpClient
+import okhttp3.logging.LoggingEventListener
+import java.io.File
+
+/**
+ * A very simple global singleton dependency graph.
+ *
+ * For a real app, you would use something like Hilt/Dagger instead.
+ */
+object Graph {
+ lateinit var okHttpClient: OkHttpClient
+ private set
+
+ lateinit var database: JetcasterDatabase
+ private set
+
+ val transactionRunner: TransactionRunner
+ get() = database.transactionRunnerDao()
+
+ val syndFeedInput by lazy { SyndFeedInput() }
+
+ val podcastRepository by lazy {
+ PodcastsRepository(
+ podcastsFetcher = podcastFetcher,
+ podcastStore = podcastStore,
+ episodeStore = episodeStore,
+ categoryStore = categoryStore,
+ transactionRunner = transactionRunner,
+ mainDispatcher = mainDispatcher
+ )
+ }
+
+ 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()
+ )
+ }
+
+ val mainDispatcher: CoroutineDispatcher
+ get() = Dispatchers.Main
+
+ val ioDispatcher: CoroutineDispatcher
+ get() = Dispatchers.IO
+
+ fun provide(context: Context) {
+ okHttpClient = OkHttpClient.Builder()
+ .cache(Cache(File(context.cacheDir, "http_cache"), 20 * 1024 * 1024))
+ .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
new file mode 100644
index 0000000000..2629ad81aa
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster
+
+import android.app.Application
+
+/**
+ * Application which sets up our dependency [Graph] with a context.
+ */
+class JetcasterApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ Graph.provide(this)
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt
new file mode 100644
index 0000000000..4a94dec8df
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 androidx.compose.Immutable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "categories",
+ indices = [
+ Index("name", unique = true)
+ ]
+)
+@Immutable
+data class Category(
+ @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
+ @ColumnInfo(name = "name") val name: String
+)
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt
new file mode 100644
index 0000000000..cabf7e9e29
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.CategoriesDao
+import com.example.jetcaster.data.room.EpisodesDao
+import com.example.jetcaster.data.room.PodcastCategoryEntryDao
+import com.example.jetcaster.data.room.PodcastsDao
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * A data repository for [Category] instances.
+ */
+class CategoryStore(
+ private val categoriesDao: CategoriesDao,
+ private val categoryEntryDao: PodcastCategoryEntryDao,
+ private val episodesDao: EpisodesDao,
+ private val podcastsDao: PodcastsDao
+) {
+ /**
+ * 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> {
+ return categoriesDao.categoriesSortedByPodcastCount(limit)
+ }
+
+ /**
+ * Returns a flow containing a list of podcasts in the category with the given [categoryId],
+ * sorted by the their last episode date.
+ */
+ fun podcastsInCategorySortedByPodcastCount(
+ categoryId: Long,
+ limit: Int = Int.MAX_VALUE
+ ): Flow> {
+ return podcastsDao.podcastsInCategorySortedByLastEpisode(categoryId, limit)
+ }
+
+ /**
+ * Returns a flow containing a list of episodes from podcasts in the category with the
+ * given [categoryId], sorted by the their last episode date.
+ */
+ fun episodesFromPodcastsInCategory(
+ categoryId: Long,
+ limit: Int = Integer.MAX_VALUE
+ ): Flow> {
+ return episodesDao.episodesFromPodcastsInCategory(categoryId, limit)
+ }
+
+ /**
+ * Adds the category to the database if it doesn't already exist.
+ *
+ * @return the id of the newly inserted/existing category
+ */
+ 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) {
+ categoryEntryDao.insert(
+ PodcastCategoryEntry(podcastUri = podcastUri, categoryId = categoryId)
+ )
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt
new file mode 100644
index 0000000000..1c84486ad8
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 androidx.compose.Immutable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import java.time.Duration
+import java.time.OffsetDateTime
+
+@Entity(
+ tableName = "episodes",
+ indices = [
+ Index("uri", unique = true),
+ Index("podcast_uri")
+ ],
+ foreignKeys = [
+ ForeignKey(
+ entity = Podcast::class,
+ parentColumns = ["uri"],
+ childColumns = ["podcast_uri"],
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ )
+ ]
+)
+@Immutable
+data class Episode(
+ @PrimaryKey @ColumnInfo(name = "uri") val uri: String,
+ @ColumnInfo(name = "podcast_uri") val podcastUri: String,
+ @ColumnInfo(name = "title") val title: String,
+ @ColumnInfo(name = "subtitle") val subtitle: String? = null,
+ @ColumnInfo(name = "summary") val summary: String? = null,
+ @ColumnInfo(name = "author") val author: String? = null,
+ @ColumnInfo(name = "published") val published: OffsetDateTime,
+ @ColumnInfo(name = "duration") val duration: Duration? = null
+)
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
new file mode 100644
index 0000000000..42af925598
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 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/EpisodeToPodcast.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt
new file mode 100644
index 0000000000..4f87ba9e05
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 androidx.room.Embedded
+import androidx.room.Ignore
+import androidx.room.Relation
+import java.util.Objects
+
+class EpisodeToPodcast {
+ @Embedded
+ lateinit var episode: Episode
+
+ @Relation(parentColumn = "podcast_uri", entityColumn = "uri")
+ lateinit var _podcasts: List
+
+ @get:Ignore
+ val podcast: Podcast
+ get() = _podcasts[0]
+
+ /**
+ * Allow consumers to destructure this class
+ */
+ operator fun component1() = episode
+ operator fun component2() = podcast
+
+ override fun equals(other: Any?): Boolean = when {
+ other === this -> true
+ other is EpisodeToPodcast -> episode == other.episode && _podcasts == other._podcasts
+ else -> false
+ }
+
+ override fun hashCode(): Int = Objects.hash(episode, _podcasts)
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt
new file mode 100644
index 0000000000..27d9f4ecc4
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt
@@ -0,0 +1,37 @@
+/*
+ * 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
+
+/**
+ * A hand selected list of feeds URLs used for the purposes of displaying real information
+ * in this sample app.
+ */
+val SampleFeeds = listOf(
+ "http://feeds.feedburner.com/blogspot/AndroidDevelopersBackstage",
+ "https://feeds.thisamericanlife.org/talpodcast",
+ "https://feeds.npr.org/510289/podcast.xml",
+ "https://feeds.99percentinvisible.org/99percentinvisible",
+ "https://www.howstuffworks.com/podcasts/stuff-you-should-know.rss",
+ "https://www.thenakedscientists.com/naked_scientists_podcast.xml",
+ "https://rss.art19.com/the-daily",
+ "https://rss.art19.com/lisk",
+ "https://omny.fm/shows/silence-is-not-an-option/playlists/podcast.rss",
+ "https://audioboom.com/channels/5025217.rss",
+ "https://feeds.simplecast.com/7PvD7RPL",
+ "https://feeds.buzzsprout.com/1006078.rss",
+ "https://feeds.megaphone.fm/HSW9992617712"
+)
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt
new file mode 100644
index 0000000000..659b81f23e
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 kotlinx.coroutines.suspendCancellableCoroutine
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+import okhttp3.internal.closeQuietly
+import java.io.IOException
+import kotlin.coroutines.resumeWithException
+
+/**
+ * Suspending wrapper around an OkHttp [Call], using [Call.enqueue].
+ */
+suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
+ enqueue(object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ continuation.resume(response) {
+ // If we have a response but we're cancelled while resuming, we need to
+ // close() the unused response
+ if (response.body != null) {
+ response.closeQuietly()
+ }
+ }
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ continuation.resumeWithException(e)
+ }
+ })
+
+ continuation.invokeOnCancellation {
+ try {
+ cancel()
+ } catch (t: Throwable) {
+ // Ignore cancel exception
+ }
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt
new file mode 100644
index 0000000000..d357d62e9a
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 androidx.compose.Immutable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "podcasts",
+ indices = [
+ Index("uri", unique = true)
+ ]
+)
+@Immutable
+data class Podcast(
+ @PrimaryKey @ColumnInfo(name = "uri") val uri: String,
+ @ColumnInfo(name = "title") val title: String,
+ @ColumnInfo(name = "description") val description: String? = null,
+ @ColumnInfo(name = "author") val author: String? = null,
+ @ColumnInfo(name = "image_url") val imageUrl: String? = null,
+ @ColumnInfo(name = "copyright") val copyright: String? = null
+)
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt
new file mode 100644
index 0000000000..8b88c86358
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 androidx.compose.Immutable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "podcast_category_entries",
+ foreignKeys = [
+ ForeignKey(
+ entity = Category::class,
+ parentColumns = ["id"],
+ childColumns = ["category_id"],
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ ),
+ ForeignKey(
+ entity = Podcast::class,
+ parentColumns = ["uri"],
+ childColumns = ["podcast_uri"],
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [
+ Index("podcast_uri", "category_id", unique = true),
+ Index("category_id"),
+ Index("podcast_uri")
+ ]
+)
+@Immutable
+data class PodcastCategoryEntry(
+ @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
+ @ColumnInfo(name = "podcast_uri") val podcastUri: String,
+ @ColumnInfo(name = "category_id") val categoryId: Long
+)
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt
new file mode 100644
index 0000000000..f1c5d3ad5e
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt
@@ -0,0 +1,147 @@
+/*
+ * 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 coil.network.HttpException
+import com.rometools.modules.itunes.EntryInformation
+import com.rometools.modules.itunes.FeedInformation
+import com.rometools.rome.feed.synd.SyndEntry
+import com.rometools.rome.feed.synd.SyndFeed
+import com.rometools.rome.io.SyndFeedInput
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.flatMapMerge
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.withContext
+import okhttp3.CacheControl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.time.Duration
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.concurrent.TimeUnit
+
+/**
+ * A class which fetches some selected podcast RSS feeds.
+ *
+ * @param okHttpClient [OkHttpClient] to use for network requests
+ * @param syndFeedInput [SyndFeedInput] to use for parsing RSS feeds.
+ * @param ioDispatcher [CoroutineDispatcher] to use for running fetch requests.
+ */
+class PodcastsFetcher(
+ private val okHttpClient: OkHttpClient,
+ private val syndFeedInput: SyndFeedInput,
+ private val ioDispatcher: CoroutineDispatcher
+) {
+
+ /**
+ * It seems that most podcast hosts do not implement HTTP caching appropriately.
+ * Instead of fetching data on every app open, we instead allow the use of 'stale'
+ * network responses (up to 8 hours).
+ */
+ private val cacheControl by lazy {
+ CacheControl.Builder().maxStale(8, TimeUnit.HOURS).build()
+ }
+
+ /**
+ * Returns a [Flow] which fetches each podcast feed and emits it in turn.
+ *
+ * The feeds are fetched concurrently, meaning that the resulting emission order may not
+ * match the order of [feedUrls].
+ */
+ operator fun invoke(feedUrls: List): Flow = feedUrls.asFlow()
+ // We use flatMapMerge here to achieve concurrent fetching/parsing of the feeds.
+ .flatMapMerge { feedUrl ->
+ flow { emit(fetchPodcast(feedUrl)) }
+ }
+
+ private suspend fun fetchPodcast(url: String): PodcastRssResponse {
+ val request = Request.Builder()
+ .url(url)
+ .cacheControl(cacheControl)
+ .build()
+
+ val response = okHttpClient.newCall(request).await()
+
+ // If the network request wasn't successful, throw an exception
+ if (!response.isSuccessful) throw HttpException(response)
+
+ // Otherwise we can parse the response using a Rome SyndFeedInput, then map it
+ // to a Podcast instance. We run this on the IO dispatcher since the parser is reading
+ // from a stream.
+ return withContext(ioDispatcher) {
+ response.body!!.use { body ->
+ syndFeedInput.build(body.charStream()).toPodcastResponse(url)
+ }
+ }
+ }
+}
+
+data class PodcastRssResponse(
+ val podcast: Podcast,
+ val episodes: List,
+ val categories: Set
+)
+
+/**
+ * Map a Rome [SyndFeed] instance to our own [Podcast] data class.
+ */
+private fun SyndFeed.toPodcastResponse(feedUrl: String): PodcastRssResponse {
+ val podcastUri = uri ?: feedUrl
+ val episodes = entries.map { it.toEpisode(podcastUri) }
+
+ val feedInfo = getModule(PodcastModuleDtd) as? FeedInformation
+ val podcast = Podcast(
+ uri = podcastUri,
+ title = title,
+ description = feedInfo?.summary ?: description,
+ author = author,
+ copyright = copyright,
+ imageUrl = feedInfo?.imageUri?.toString()
+ )
+
+ val categories = feedInfo?.categories
+ ?.map { Category(name = it.name) }
+ ?.toSet() ?: emptySet()
+
+ return PodcastRssResponse(podcast, episodes, categories)
+}
+
+/**
+ * Map a Rome [SyndEntry] instance to our own [Episode] data class.
+ */
+private fun SyndEntry.toEpisode(podcastUri: String): Episode {
+ val entryInformation = getModule(PodcastModuleDtd) as? EntryInformation
+ return Episode(
+ uri = uri,
+ podcastUri = podcastUri,
+ title = title,
+ author = author,
+ summary = entryInformation?.summary ?: description?.value,
+ subtitle = entryInformation?.subtitle,
+ published = Instant.ofEpochMilli(publishedDate.time).atOffset(ZoneOffset.UTC),
+ duration = entryInformation?.duration?.milliseconds?.let { Duration.ofMillis(it) }
+ )
+}
+
+/**
+ * Most feeds use the following DTD to include extra information related to
+ * their podcast. Info such as images, summaries, duration, categories is sometimes only available
+ * via this attributes in this DTD.
+ */
+private const val PodcastModuleDtd = "http://www.itunes.com/dtds/podcast-1.0.dtd"
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt
new file mode 100644
index 0000000000..56938689ea
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt
@@ -0,0 +1,45 @@
+/*
+ * 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 androidx.compose.Immutable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "podcast_followed_entries",
+ foreignKeys = [
+ ForeignKey(
+ entity = Podcast::class,
+ parentColumns = ["uri"],
+ childColumns = ["podcast_uri"],
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [
+ Index("podcast_uri", unique = true)
+ ]
+)
+@Immutable
+data class PodcastFollowedEntry(
+ @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
+ @ColumnInfo(name = "podcast_uri") val podcastUri: String
+)
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
new file mode 100644
index 0000000000..fbeaa4031e
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt
@@ -0,0 +1,85 @@
+/*
+ * 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)
+ }
+
+ 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/data/PodcastWithExtraInfo.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt
new file mode 100644
index 0000000000..200e6248c2
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt
@@ -0,0 +1,52 @@
+/*
+ * 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 androidx.room.ColumnInfo
+import androidx.room.Embedded
+import java.time.OffsetDateTime
+import java.util.Objects
+
+class PodcastWithExtraInfo {
+ @Embedded
+ lateinit var podcast: Podcast
+
+ @ColumnInfo(name = "last_episode_date")
+ var lastEpisodeDate: OffsetDateTime? = null
+
+ @ColumnInfo(name = "is_followed")
+ var isFollowed: Boolean = false
+
+ /**
+ * Allow consumers to destructure this class
+ */
+ operator fun component1() = podcast
+ operator fun component2() = lastEpisodeDate
+ operator fun component3() = isFollowed
+
+ override fun equals(other: Any?): Boolean = when {
+ other === this -> true
+ other is PodcastWithExtraInfo -> {
+ podcast == other.podcast &&
+ lastEpisodeDate == other.lastEpisodeDate &&
+ isFollowed == other.isFollowed
+ }
+ else -> false
+ }
+
+ override fun hashCode(): Int = Objects.hash(podcast, lastEpisodeDate, isFollowed)
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt
new file mode 100644
index 0000000000..2762890283
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.TransactionRunner
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+/**
+ * Data repository for Podcasts.
+ */
+class PodcastsRepository(
+ private val podcastsFetcher: PodcastsFetcher,
+ private val podcastStore: PodcastStore,
+ private val episodeStore: EpisodeStore,
+ private val categoryStore: CategoryStore,
+ private val transactionRunner: TransactionRunner,
+ private val mainDispatcher: CoroutineDispatcher
+) {
+ private var refreshingJob: Job? = null
+
+ private val scope = CoroutineScope(mainDispatcher)
+
+ suspend fun updatePodcasts(force: Boolean) {
+ if (refreshingJob?.isActive == true) {
+ refreshingJob?.join()
+ } else if (force || podcastStore.isEmpty()) {
+ refreshingJob = scope.launch {
+ // Now fetch the podcasts, and add each to each store
+ podcastsFetcher(SampleFeeds).collect { (podcast, episodes, categories) ->
+ transactionRunner {
+ podcastStore.addPodcast(podcast)
+ episodeStore.addEpisodes(episodes)
+
+ categories.forEach { category ->
+ // First insert the category
+ val categoryId = categoryStore.addCategory(category)
+ // Now we can add the podcast to the category
+ categoryStore.addPodcastToCategory(
+ podcastUri = podcast.uri,
+ categoryId = categoryId
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt
new file mode 100644
index 0000000000..d21d28d65b
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.room
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.example.jetcaster.data.Category
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * [Room] DAO for [Category] related operations.
+ */
+@Dao
+abstract class CategoriesDao {
+ @Query(
+ """
+ SELECT categories.* FROM categories
+ INNER JOIN (
+ SELECT category_id, COUNT(podcast_uri) AS podcast_count FROM podcast_category_entries
+ GROUP BY category_id
+ ) ON category_id = categories.id
+ ORDER BY podcast_count DESC
+ LIMIT :limit
+ """
+ )
+ abstract fun categoriesSortedByPodcastCount(
+ limit: Int
+ ): Flow>
+
+ @Query("SELECT * FROM categories WHERE name = :name")
+ abstract suspend fun getCategoryWithName(name: String): Category?
+
+ /**
+ * The following methods should really live in a base interface. Unfortunately the Kotlin
+ * Compiler which we need to use for Compose doesn't work with that.
+ * TODO: remove this once we move to a more recent Kotlin compiler
+ */
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insert(entity: Category): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(vararg entity: Category)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(entities: Collection)
+
+ @Update(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun update(entity: Category)
+
+ @Delete
+ abstract suspend fun delete(entity: Category): Int
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt
new file mode 100644
index 0000000000..4b4fb5d0a9
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.room
+
+import androidx.room.TypeConverter
+import java.time.Duration
+import java.time.LocalDateTime
+import java.time.OffsetDateTime
+import java.time.format.DateTimeFormatter
+
+/**
+ * Room [TypeConverter] functions for various `java.time.*` classes.
+ */
+object DateTimeTypeConverters {
+ @TypeConverter
+ @JvmStatic
+ fun toOffsetDateTime(value: String?): OffsetDateTime? {
+ return value?.let { OffsetDateTime.parse(it) }
+ }
+
+ @TypeConverter
+ @JvmStatic
+ fun fromOffsetDateTime(date: OffsetDateTime?): String? {
+ return date?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+ }
+
+ @TypeConverter
+ @JvmStatic
+ fun toLocalDateTime(value: String?): LocalDateTime? {
+ return value?.let { LocalDateTime.parse(value) }
+ }
+
+ @TypeConverter
+ @JvmStatic
+ fun fromLocalDateTime(value: LocalDateTime?): String? {
+ return value?.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
+ }
+
+ @TypeConverter
+ @JvmStatic
+ fun toDuration(value: Long?): Duration? {
+ return value?.let { Duration.ofMillis(it) }
+ }
+
+ @TypeConverter
+ @JvmStatic
+ fun fromDuration(value: Duration?): Long? {
+ return value?.toMillis()
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt
new file mode 100644
index 0000000000..b1c0335a3d
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.room
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Update
+import com.example.jetcaster.data.Episode
+import com.example.jetcaster.data.EpisodeToPodcast
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * [Room] DAO for [Episode] related operations.
+ */
+@Dao
+abstract class EpisodesDao {
+
+ @Query(
+ """
+ SELECT * FROM episodes WHERE podcast_uri = :podcastUri
+ ORDER BY datetime(published) DESC
+ LIMIT :limit
+ """
+ )
+ abstract fun episodesForPodcastUri(
+ podcastUri: String,
+ limit: Int
+ ): Flow>
+
+ @Transaction
+ @Query(
+ """
+ SELECT episodes.* FROM episodes
+ INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri
+ WHERE category_id = :categoryId
+ ORDER BY datetime(published) DESC
+ LIMIT :limit
+ """
+ )
+ abstract fun episodesFromPodcastsInCategory(
+ categoryId: Long,
+ limit: Int
+ ): Flow>
+
+ @Query("SELECT COUNT(*) FROM episodes")
+ abstract suspend fun count(): Int
+
+ /**
+ * The following methods should really live in a base interface. Unfortunately the Kotlin
+ * Compiler which we need to use for Compose doesn't work with that.
+ * TODO: remove this once we move to a more recent Kotlin compiler
+ */
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insert(entity: Episode): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(vararg entity: Episode)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(entities: Collection)
+
+ @Update(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun update(entity: Episode)
+
+ @Delete
+ abstract suspend fun delete(entity: Episode): Int
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt
new file mode 100644
index 0000000000..cc4f2a24e7
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.room
+
+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
+
+/**
+ * The [RoomDatabase] we use in this app.
+ */
+@Database(
+ entities = [
+ Podcast::class,
+ Episode::class,
+ PodcastCategoryEntry::class,
+ Category::class,
+ PodcastFollowedEntry::class
+ ],
+ version = 1,
+ exportSchema = false
+)
+@TypeConverters(DateTimeTypeConverters::class)
+abstract class JetcasterDatabase : RoomDatabase() {
+ abstract fun podcastsDao(): PodcastsDao
+ abstract fun episodesDao(): EpisodesDao
+ abstract fun categoriesDao(): CategoriesDao
+ abstract fun podcastCategoryEntryDao(): PodcastCategoryEntryDao
+ abstract fun transactionRunnerDao(): TransactionRunnerDao
+ abstract fun podcastFollowedEntryDao(): PodcastFollowedEntryDao
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt
new file mode 100644
index 0000000000..5dad731f0e
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.room
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Update
+import com.example.jetcaster.data.PodcastCategoryEntry
+
+/**
+ * [Room] DAO for [PodcastCategoryEntry] related operations.
+ */
+@Dao
+abstract class PodcastCategoryEntryDao {
+ /**
+ * The following methods should really live in a base interface. Unfortunately the Kotlin
+ * Compiler which we need to use for Compose doesn't work with that.
+ * TODO: remove this once we move to a more recent Kotlin compiler
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insert(entity: PodcastCategoryEntry): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(vararg entity: PodcastCategoryEntry)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(entities: Collection)
+
+ @Update(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun update(entity: PodcastCategoryEntry)
+
+ @Delete
+ abstract suspend fun delete(entity: PodcastCategoryEntry): Int
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt
new file mode 100644
index 0000000000..2a56b3d63c
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.room
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.example.jetcaster.data.PodcastFollowedEntry
+
+@Dao
+abstract class PodcastFollowedEntryDao {
+ @Query("DELETE FROM podcast_followed_entries WHERE podcast_uri = :podcastUri")
+ abstract suspend fun deleteWithPodcastUri(podcastUri: String)
+
+ @Query("SELECT COUNT(*) FROM podcast_followed_entries WHERE podcast_uri = :podcastUri")
+ protected abstract suspend fun podcastFollowRowCount(podcastUri: String): Int
+
+ suspend fun isPodcastFollowed(podcastUri: String): Boolean {
+ return podcastFollowRowCount(podcastUri) > 0
+ }
+
+ /**
+ * The following methods should really live in a base interface. Unfortunately the Kotlin
+ * Compiler which we need to use for Compose doesn't work with.
+ * TODO: remove this once we move to a more recent Kotlin compiler
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insert(entity: PodcastFollowedEntry): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(vararg entity: PodcastFollowedEntry)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(entities: Collection)
+
+ @Update(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun update(entity: PodcastFollowedEntry)
+
+ @Delete
+ abstract suspend fun delete(entity: PodcastFollowedEntry): Int
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt
new file mode 100644
index 0000000000..6ae0baa2bb
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt
@@ -0,0 +1,119 @@
+/*
+ * 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.room
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Update
+import com.example.jetcaster.data.Podcast
+import com.example.jetcaster.data.PodcastWithExtraInfo
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * [Room] DAO for [Podcast] related operations.
+ */
+@Dao
+abstract class PodcastsDao {
+ @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 = episodes.podcast_uri
+ ORDER BY datetime(last_episode_date) DESC
+ LIMIT :limit
+ """
+ )
+ abstract fun podcastsSortedByLastEpisode(
+ 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 = :categoryId
+ GROUP BY episodes.podcast_uri
+ ) inner_query ON podcasts.uri = inner_query.podcast_uri
+ LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri
+ ORDER BY datetime(last_episode_date) DESC
+ LIMIT :limit
+ """
+ )
+ abstract fun podcastsInCategorySortedByLastEpisode(
+ categoryId: Long,
+ limit: Int
+ ): Flow>
+
+ @Transaction
+ @Query(
+ """
+ SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed
+ FROM podcasts
+ INNER JOIN (
+ SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri
+ ) episodes ON podcasts.uri = episodes.podcast_uri
+ INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri
+ ORDER BY datetime(last_episode_date) DESC
+ LIMIT :limit
+ """
+ )
+ abstract fun followedPodcastsSortedByLastEpisode(
+ limit: Int
+ ): Flow>
+
+ @Query("SELECT COUNT(*) FROM podcasts")
+ abstract suspend fun count(): Int
+
+ /**
+ * The following methods should really live in a base interface. Unfortunately the Kotlin
+ * Compiler which we need to use for Compose doesn't work with that.
+ * TODO: remove this once we move to a more recent Kotlin compiler
+ */
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insert(entity: Podcast): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(vararg entity: Podcast)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insertAll(entities: Collection)
+
+ @Update(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun update(entity: Podcast)
+
+ @Delete
+ abstract suspend fun delete(entity: Podcast): Int
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt
new file mode 100644
index 0000000000..e7c51cad4f
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.room
+
+import androidx.room.Dao
+import androidx.room.Ignore
+import androidx.room.Transaction
+
+/**
+ * [Room] DAO which provides the implementation for our [TransactionRunner].
+ */
+@Dao
+abstract class TransactionRunnerDao : TransactionRunner {
+ @Transaction
+ protected open suspend fun runInTransaction(tx: suspend () -> Unit) = tx()
+
+ @Ignore
+ override suspend fun invoke(tx: suspend () -> Unit) {
+ runInTransaction(tx)
+ }
+}
+
+/**
+ * Interface with operator function which will invoke the suspending lambda within a database
+ * transaction.
+ */
+interface TransactionRunner {
+ suspend operator fun invoke(tx: suspend () -> Unit)
+}
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
new file mode 100644
index 0000000000..ac5655efd9
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt
@@ -0,0 +1,26 @@
+/*
+ * 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
+
+import androidx.compose.Composable
+import com.example.jetcaster.ui.home.Home
+
+@Composable
+fun JetcasterApp() {
+ // TODO: add some navigation
+ Home()
+}
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
new file mode 100644
index 0000000000..58a0369316
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt
@@ -0,0 +1,47 @@
+/*
+ * 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
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.ui.core.setContent
+import com.example.jetcaster.ui.theme.JetcasterTheme
+import com.example.jetcaster.util.ProvideDisplayInsets
+
+class MainActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ /**
+ * TODO: Move to WindowCompat.setDecorFitsSystemWindows() when it lands in
+ * android.core:core 1.5.0-alpha02
+ */
+ @Suppress("DEPRECATION")
+ window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+
+ setContent {
+ JetcasterTheme {
+ ProvideDisplayInsets {
+ JetcasterApp()
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000000..ffc36edc7c
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt
@@ -0,0 +1,368 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.ui.home
+
+import androidx.compose.Composable
+import androidx.compose.collectAsState
+import androidx.compose.getValue
+import androidx.compose.remember
+import androidx.ui.core.Alignment
+import androidx.ui.core.AnimationClockAmbient
+import androidx.ui.core.ContentScale
+import androidx.ui.core.Modifier
+import androidx.ui.core.clip
+import androidx.ui.foundation.Icon
+import androidx.ui.foundation.Text
+import androidx.ui.foundation.drawBackground
+import androidx.ui.foundation.shape.corner.RoundedCornerShape
+import androidx.ui.graphics.Color
+import androidx.ui.layout.Column
+import androidx.ui.layout.ColumnScope.gravity
+import androidx.ui.layout.Spacer
+import androidx.ui.layout.Stack
+import androidx.ui.layout.aspectRatio
+import androidx.ui.layout.fillMaxHeight
+import androidx.ui.layout.fillMaxSize
+import androidx.ui.layout.fillMaxWidth
+import androidx.ui.layout.height
+import androidx.ui.layout.padding
+import androidx.ui.layout.preferredHeight
+import androidx.ui.layout.preferredHeightIn
+import androidx.ui.layout.preferredWidth
+import androidx.ui.layout.size
+import androidx.ui.material.EmphasisAmbient
+import androidx.ui.material.IconButton
+import androidx.ui.material.MaterialTheme
+import androidx.ui.material.ProvideEmphasis
+import androidx.ui.material.Surface
+import androidx.ui.material.Tab
+import androidx.ui.material.TabRow
+import androidx.ui.material.icons.Icons
+import androidx.ui.material.icons.filled.AccountCircle
+import androidx.ui.material.icons.filled.Search
+import androidx.ui.res.stringResource
+import androidx.ui.res.vectorResource
+import androidx.ui.text.style.TextOverflow
+import androidx.ui.tooling.preview.Preview
+import androidx.ui.unit.dp
+import androidx.ui.viewmodel.viewModel
+import com.example.jetcaster.R
+import com.example.jetcaster.data.PodcastWithExtraInfo
+import com.example.jetcaster.ui.home.discover.Discover
+import com.example.jetcaster.ui.theme.JetcasterTheme
+import com.example.jetcaster.ui.theme.Keyline1
+import com.example.jetcaster.util.DominantColorVerticalGradient
+import com.example.jetcaster.util.Pager
+import com.example.jetcaster.util.PagerState
+import com.example.jetcaster.util.ToggleFollowPodcastIconButton
+import com.example.jetcaster.util.quantityStringResource
+import com.example.jetcaster.util.statusBarPadding
+import dev.chrisbanes.accompanist.coil.CoilImage
+import java.time.Duration
+import java.time.LocalDateTime
+import java.time.OffsetDateTime
+
+@Composable
+fun Home() {
+ val viewModel: HomeViewModel = viewModel()
+
+ val viewState by viewModel.state.collectAsState()
+
+ Surface(Modifier.fillMaxSize()) {
+ HomeContent(
+ featuredPodcasts = viewState.featuredPodcasts,
+ isRefreshing = viewState.refreshing,
+ homeCategories = viewState.homeCategories,
+ selectedHomeCategory = viewState.selectedHomeCategory,
+ onCategorySelected = viewModel::onHomeCategorySelected,
+ onPodcastUnfollowed = viewModel::onPodcastUnfollowed,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+}
+
+@Composable
+fun HomeAppBar(
+ modifier: Modifier = Modifier
+) {
+ Stack(modifier = modifier) {
+ ProvideEmphasis(EmphasisAmbient.current.high) {
+ Icon(
+ asset = vectorResource(R.drawable.ic_text_logo),
+ modifier = Modifier.gravity(Alignment.Center)
+ .padding(8.dp)
+ .preferredHeightIn(maxHeight = 24.dp)
+ )
+ }
+
+ ProvideEmphasis(EmphasisAmbient.current.medium) {
+ IconButton(
+ onClick = { /* TODO: Open account? */ },
+ modifier = Modifier.gravity(Alignment.CenterStart)
+ .padding(start = 8.dp)
+ ) {
+ Icon(Icons.Default.AccountCircle)
+ }
+
+ IconButton(
+ onClick = { /* TODO: Open search */ },
+ modifier = Modifier.gravity(Alignment.CenterEnd)
+ .padding(end = 8.dp)
+ ) {
+ Icon(Icons.Filled.Search)
+ }
+ }
+ }
+}
+
+@Composable
+fun HomeContent(
+ featuredPodcasts: List,
+ isRefreshing: Boolean,
+ selectedHomeCategory: HomeCategory,
+ homeCategories: List,
+ modifier: Modifier = Modifier,
+ onPodcastUnfollowed: (String) -> Unit,
+ onCategorySelected: (HomeCategory) -> Unit
+) {
+ Column(modifier = modifier) {
+ Stack(Modifier.fillMaxWidth()) {
+ val clock = AnimationClockAmbient.current
+ val pagerState = remember(clock) { PagerState(clock) }
+
+ DominantColorVerticalGradient(
+ imageSourceUrl = featuredPodcasts.getOrNull(pagerState.currentPage)
+ ?.podcast?.imageUrl,
+ modifier = Modifier.matchParentSize()
+ )
+
+ Column(Modifier.fillMaxWidth()) {
+ HomeAppBar(
+ modifier = Modifier.fillMaxWidth()
+ .statusBarPadding()
+ .preferredHeight(56.dp) /* TODO: change height to 48.dp in landscape */
+ )
+
+ if (featuredPodcasts.isNotEmpty()) {
+ Spacer(Modifier.height(16.dp))
+
+ FollowedPodcasts(
+ items = featuredPodcasts,
+ pagerState = pagerState,
+ onPodcastUnfollowed = onPodcastUnfollowed,
+ modifier = Modifier
+ .padding(start = Keyline1, top = 16.dp, end = Keyline1)
+ .fillMaxWidth()
+ .preferredHeight(200.dp)
+ )
+
+ Spacer(Modifier.height(16.dp))
+ }
+ }
+ }
+
+ if (isRefreshing) {
+ // TODO show a progress indicator or similar
+ }
+
+ if (homeCategories.isNotEmpty()) {
+ HomeCategoryTabs(
+ categories = homeCategories,
+ selectedCategory = selectedHomeCategory,
+ onCategorySelected = onCategorySelected
+ )
+ }
+
+ when (selectedHomeCategory) {
+ HomeCategory.Library -> {
+ // TODO
+ }
+ HomeCategory.Discover -> {
+ Discover(Modifier.fillMaxWidth().weight(1f))
+ }
+ }
+ }
+}
+
+@Composable
+private fun HomeCategoryTabs(
+ categories: List,
+ selectedCategory: HomeCategory,
+ onCategorySelected: (HomeCategory) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val selectedIndex = categories.indexOfFirst { it == selectedCategory }
+ TabRow(
+ items = categories,
+ selectedIndex = selectedIndex,
+ indicatorContainer = { tabPositions ->
+ TabRow.IndicatorContainer(tabPositions, selectedIndex) {
+ HomeCategoryTabIndicator()
+ }
+ },
+ modifier = modifier
+ ) { index, category ->
+ Tab(
+ selected = index == selectedIndex,
+ onSelected = { onCategorySelected(category) },
+ text = {
+ Text(
+ text = stringResource(
+ when (category) {
+ HomeCategory.Library -> R.string.home_library
+ HomeCategory.Discover -> R.string.home_discover
+ }
+ )
+ )
+ }
+ )
+ }
+}
+
+@Composable
+fun HomeCategoryTabIndicator(
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colors.primary
+) {
+ Spacer(
+ modifier.preferredWidth(112.dp)
+ .preferredHeight(4.dp)
+ .gravity(Alignment.CenterHorizontally)
+ .drawBackground(color, RoundedCornerShape(topLeftPercent = 100, topRightPercent = 100))
+ )
+}
+
+@Composable
+fun FollowedPodcasts(
+ items: List,
+ pagerState: PagerState = run {
+ val clock = AnimationClockAmbient.current
+ remember(clock) { PagerState(clock) }
+ },
+ onPodcastUnfollowed: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ pagerState.maxPage = (items.size - 1).coerceAtLeast(0)
+
+ Pager(
+ state = pagerState,
+ modifier = modifier
+ ) {
+ val (podcast, lastEpisodeDate) = items[page]
+ FollowedPodcastCarouselItem(
+ podcastImageUrl = podcast.imageUrl,
+ lastEpisodeDate = lastEpisodeDate,
+ onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) },
+ modifier = Modifier.padding(4.dp)
+ .fillMaxHeight()
+ .scalePagerItems(unselectedScale = PodcastCarouselUnselectedScale)
+ )
+ }
+}
+
+private const val PodcastCarouselUnselectedScale = 0.85f
+
+@Composable
+private fun FollowedPodcastCarouselItem(
+ podcastImageUrl: String? = null,
+ lastEpisodeDate: OffsetDateTime? = null,
+ onUnfollowedClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier.padding(horizontal = 12.dp, vertical = 8.dp)
+ ) {
+ Stack(
+ Modifier
+ .weight(1f)
+ .gravity(Alignment.CenterHorizontally)
+ .aspectRatio(1f)
+ ) {
+ if (podcastImageUrl != null) {
+ CoilImage(
+ data = podcastImageUrl,
+ contentScale = ContentScale.Crop,
+ loading = { /* TODO do something better here */ },
+ modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.medium)
+ )
+ }
+
+ ProvideEmphasis(EmphasisAmbient.current.high) {
+ ToggleFollowPodcastIconButton(
+ onClick = onUnfollowedClick,
+ isFollowed = true, /* All podcasts are followed in this feed */
+ modifier = Modifier.gravity(Alignment.BottomEnd)
+ )
+ }
+ }
+
+ if (lastEpisodeDate != null) {
+ ProvideEmphasis(EmphasisAmbient.current.medium) {
+ Text(
+ text = lastUpdated(lastEpisodeDate),
+ style = MaterialTheme.typography.caption,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 8.dp)
+ .gravity(Alignment.CenterHorizontally)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun lastUpdated(updated: OffsetDateTime): String {
+ val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now())
+ val days = duration.toDays().toInt()
+
+ return when {
+ days > 28 -> stringResource(R.string.updated_longer)
+ days >= 7 -> {
+ val weeks = days / 7
+ quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks)
+ }
+ days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days)
+ else -> stringResource(R.string.updated_today)
+ }
+}
+
+@Composable
+@Preview
+fun PreviewHomeContent() {
+ JetcasterTheme {
+ HomeContent(
+ featuredPodcasts = PreviewPodcastsWithExtraInfo,
+ isRefreshing = false,
+ homeCategories = HomeCategory.values().asList(),
+ selectedHomeCategory = HomeCategory.Discover,
+ onCategorySelected = {},
+ onPodcastUnfollowed = {}
+ )
+ }
+}
+
+@Composable
+@Preview
+fun PreviewPodcastCard() {
+ JetcasterTheme {
+ FollowedPodcastCarouselItem(
+ modifier = Modifier.size(128.dp),
+ onUnfollowedClick = {}
+ )
+ }
+}
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
new file mode 100644
index 0000000000..e1e1170b22
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.ui.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.jetcaster.Graph
+import com.example.jetcaster.data.PodcastStore
+import com.example.jetcaster.data.PodcastWithExtraInfo
+import com.example.jetcaster.data.PodcastsRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+class HomeViewModel(
+ private val podcastsRepository: PodcastsRepository = Graph.podcastRepository,
+ private val podcastStore: PodcastStore = Graph.podcastStore
+) : ViewModel() {
+ // Holds our currently selected home category
+ private val selectedCategory = MutableStateFlow(HomeCategory.Discover)
+ // Holds the currently available home categories
+ private val categories = MutableStateFlow(HomeCategory.values().asList())
+
+ // Holds our view state which the UI collects via [state]
+ private val _state = MutableStateFlow(HomeViewState())
+
+ private val refreshing = MutableStateFlow(false)
+
+ val state: StateFlow
+ get() = _state
+
+ init {
+ viewModelScope.launch {
+ // Combines the latest value from each of the flows, allowing us to generate a
+ // view state instance which only contains the latest values.
+ combine(
+ categories,
+ selectedCategory,
+ podcastStore.followedPodcastsSortedByLastEpisode(limit = 20),
+ refreshing
+ ) { categories, selectedCategory, podcasts, refreshing ->
+ HomeViewState(
+ homeCategories = categories,
+ selectedHomeCategory = selectedCategory,
+ featuredPodcasts = podcasts,
+ refreshing = refreshing,
+ errorMessage = null /* TODO */
+ )
+ }.catch { throwable ->
+ // TODO: emit a UI error here. For now we'll just rethrow
+ throw throwable
+ }.collect {
+ _state.value = it
+ }
+ }
+
+ refresh(force = false)
+ }
+
+ fun refresh(force: Boolean) {
+ viewModelScope.launch {
+ runCatching {
+ refreshing.value = true
+ podcastsRepository.updatePodcasts(force)
+ }
+ // TODO: look at result of runCatching and show any errors
+
+ refreshing.value = false
+ }
+ }
+
+ fun onHomeCategorySelected(category: HomeCategory) {
+ selectedCategory.value = category
+ }
+
+ fun onPodcastUnfollowed(podcastUri: String) {
+ viewModelScope.launch {
+ podcastStore.unfollowPodcast(podcastUri)
+ }
+ }
+}
+
+enum class HomeCategory {
+ Library, Discover
+}
+
+data class HomeViewState(
+ val featuredPodcasts: List = emptyList(),
+ val refreshing: Boolean = false,
+ val selectedHomeCategory: HomeCategory = HomeCategory.Discover,
+ val homeCategories: List = emptyList(),
+ val errorMessage: String? = null
+)
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PodcastCategory.kt
new file mode 100644
index 0000000000..1c8faea741
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PodcastCategory.kt
@@ -0,0 +1,384 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.ui.home
+
+import androidx.compose.Composable
+import androidx.compose.collectAsState
+import androidx.compose.getValue
+import androidx.ui.core.Alignment
+import androidx.ui.core.ContentScale
+import androidx.ui.core.Modifier
+import androidx.ui.core.clip
+import androidx.ui.foundation.Icon
+import androidx.ui.foundation.Image
+import androidx.ui.foundation.Text
+import androidx.ui.foundation.clickable
+import androidx.ui.foundation.contentColor
+import androidx.ui.foundation.lazy.LazyColumnItems
+import androidx.ui.foundation.lazy.LazyRowItems
+import androidx.ui.graphics.ColorFilter
+import androidx.ui.layout.Column
+import androidx.ui.layout.ConstraintLayout
+import androidx.ui.layout.Dimension
+import androidx.ui.layout.Spacer
+import androidx.ui.layout.Stack
+import androidx.ui.layout.aspectRatio
+import androidx.ui.layout.fillMaxSize
+import androidx.ui.layout.fillMaxWidth
+import androidx.ui.layout.padding
+import androidx.ui.layout.preferredHeight
+import androidx.ui.layout.preferredSize
+import androidx.ui.layout.preferredWidth
+import androidx.ui.material.Divider
+import androidx.ui.material.EmphasisAmbient
+import androidx.ui.material.IconButton
+import androidx.ui.material.MaterialTheme
+import androidx.ui.material.ProvideEmphasis
+import androidx.ui.material.icons.Icons
+import androidx.ui.material.icons.filled.MoreVert
+import androidx.ui.material.icons.filled.PlayCircleOutline
+import androidx.ui.material.icons.filled.PlaylistAdd
+import androidx.ui.res.stringResource
+import androidx.ui.text.style.TextOverflow
+import androidx.ui.tooling.preview.Preview
+import androidx.ui.unit.dp
+import androidx.ui.viewmodel.viewModel
+import com.example.jetcaster.R
+import com.example.jetcaster.data.Episode
+import com.example.jetcaster.data.Podcast
+import com.example.jetcaster.data.PodcastWithExtraInfo
+import com.example.jetcaster.ui.theme.JetcasterTheme
+import com.example.jetcaster.ui.theme.Keyline1
+import com.example.jetcaster.util.ToggleFollowPodcastIconButton
+import com.example.jetcaster.util.viewModelProviderFactoryOf
+import dev.chrisbanes.accompanist.coil.CoilImage
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@Composable
+fun PodcastCategory(
+ categoryId: Long,
+ modifier: Modifier = Modifier
+) {
+ /**
+ * CategoryEpisodeListViewModel requires the category as part of it's constructor, therefore
+ * we need to assist with it's instantiation with a custom factory and custom key.
+ */
+ val viewModel: PodcastCategoryViewModel = viewModel(
+ // We use a custom key, using the category parameter
+ key = "category_list_$categoryId",
+ factory = viewModelProviderFactoryOf { PodcastCategoryViewModel(categoryId) }
+ )
+
+ val viewState by viewModel.state.collectAsState()
+
+ /**
+ * LazyColumnItems currently only supports a single type of item. To workaround that, we
+ * have the `sealed` [EpisodeItem] class which allows us to bake in different 'layout' types,
+ * which our [LazyColumnItems] switches on.
+ */
+ val items = ArrayList()
+ if (viewState.topPodcasts.isNotEmpty()) {
+ items += PodcastCategoryItem.TopPodcastsItem(viewState.topPodcasts)
+ }
+ viewState.episodes.mapTo(items) { (episode, podcast) ->
+ PodcastCategoryItem.EpisodeItem(episode, podcast)
+ }
+
+ /**
+ * TODO: reset scroll position when category changes
+ */
+ LazyColumnItems(
+ items = items,
+ modifier = modifier
+ ) { item ->
+ when (item) {
+ is PodcastCategoryItem.EpisodeItem -> {
+ EpisodeListItem(
+ episode = item.episode,
+ podcast = item.podcast,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ is PodcastCategoryItem.TopPodcastsItem -> {
+ CategoryPodcastRow(
+ podcasts = item.podcasts,
+ onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed,
+ modifier = Modifier
+ .padding(horizontal = 12.dp, vertical = 8.dp)
+ // Ideally this would wrap height but LazyRowItems doesn't yet support
+ // wrapping height
+ .preferredHeight(188.dp)
+ )
+ }
+ }
+ }
+}
+
+@Suppress("UNUSED_VARIABLE")
+@Composable
+fun EpisodeListItem(
+ episode: Episode,
+ podcast: Podcast,
+ modifier: Modifier = Modifier
+) {
+ ConstraintLayout(
+ modifier = Modifier.clickable { /* TODO */ }.plus(modifier)
+ ) {
+ val (
+ divider, episodeTitle, podcastTitle, summary, image, playIcon,
+ date, duration, addPlaylist, overflow
+ ) = createRefs()
+
+ Divider(
+ Modifier.constrainAs(divider) {
+ top.linkTo(parent.top)
+ centerHorizontallyTo(parent)
+
+ width = Dimension.fillToConstraints
+ }
+ )
+
+ if (podcast.imageUrl != null) {
+ // If we have an image Url, we can show it using [CoilImage]
+ CoilImage(
+ data = podcast.imageUrl,
+ contentScale = ContentScale.Crop,
+ loading = { /* TODO do something better here */ },
+ modifier = Modifier.preferredSize(48.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .constrainAs(image) {
+ end.linkTo(parent.end, 16.dp)
+ top.linkTo(parent.top, 16.dp)
+ }
+ )
+ } else {
+ // If we don't have an image url, we need to make sure that the constraint reference
+ // still makes senses for our siblings. We add a zero sized spacer in the spacer
+ // origin position (top-end) with the same margin
+ Spacer(
+ Modifier.constrainAs(image) {
+ end.linkTo(parent.end, 16.dp)
+ top.linkTo(parent.top, 16.dp)
+ }
+ )
+ }
+
+ ProvideEmphasis(EmphasisAmbient.current.high) {
+ Text(
+ text = episode.title,
+ maxLines = 2,
+ 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)
+
+ width = Dimension.preferredWrapContent
+ }
+ )
+ }
+
+ val titleImageBarrier = createBottomBarrier(podcastTitle, image)
+
+ ProvideEmphasis(EmphasisAmbient.current.medium) {
+ Text(
+ text = podcast.title,
+ maxLines = 2,
+ style = MaterialTheme.typography.caption,
+ modifier = Modifier.constrainAs(podcastTitle) {
+ linkTo(
+ start = parent.start,
+ end = image.start,
+ startMargin = Keyline1,
+ endMargin = 16.dp,
+ bias = 0f
+ )
+ top.linkTo(episodeTitle.bottom, 4.dp)
+
+ width = Dimension.preferredWrapContent
+ }
+ )
+
+ episode.summary?.let {
+ Text(
+ text = it,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.caption,
+ modifier = Modifier.constrainAs(summary) {
+ start.linkTo(parent.start, Keyline1)
+ end.linkTo(image.end)
+ top.linkTo(titleImageBarrier, 16.dp)
+
+ width = Dimension.fillToConstraints
+ }
+ )
+ }
+ }
+
+ ProvideEmphasis(EmphasisAmbient.current.high) {
+ Image(
+ asset = Icons.Default.PlayCircleOutline,
+ contentScale = ContentScale.Fit,
+ colorFilter = ColorFilter.tint(contentColor()),
+ modifier = Modifier
+ .clickable { /* TODO */ }
+ .preferredSize(48.dp)
+ .constrainAs(playIcon) {
+ start.linkTo(parent.start, Keyline1)
+ top.linkTo(summary.bottom, margin = 16.dp)
+ bottom.linkTo(parent.bottom, 16.dp)
+ }
+ )
+ }
+
+ ProvideEmphasis(EmphasisAmbient.current.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) {
+ start.linkTo(playIcon.end, margin = 16.dp)
+ end.linkTo(addPlaylist.start, margin = 16.dp)
+ centerVerticallyTo(playIcon)
+
+ width = Dimension.fillToConstraints
+ }
+ )
+
+ IconButton(
+ onClick = { /* TODO */ },
+ icon = { Icon(Icons.Default.PlaylistAdd) },
+ modifier = Modifier.constrainAs(addPlaylist) {
+ end.linkTo(overflow.start)
+ centerVerticallyTo(playIcon)
+ }
+ )
+
+ IconButton(
+ onClick = { /* TODO */ },
+ icon = { Icon(Icons.Default.MoreVert) },
+ modifier = Modifier.constrainAs(overflow) {
+ end.linkTo(parent.end, 8.dp)
+ centerVerticallyTo(playIcon)
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun CategoryPodcastRow(
+ podcasts: List,
+ onTogglePodcastFollowed: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyRowItems(items = podcasts, modifier = modifier) { (podcast, _, isFollowed) ->
+ TopPodcastRowItem(
+ podcastTitle = podcast.title,
+ podcastImageUrl = podcast.imageUrl,
+ isFollowed = isFollowed,
+ onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) },
+ modifier = Modifier.padding(8.dp).preferredWidth(128.dp)
+ )
+ }
+}
+
+@Composable
+private fun TopPodcastRowItem(
+ podcastTitle: String,
+ isFollowed: Boolean,
+ onToggleFollowClicked: () -> Unit,
+ podcastImageUrl: String? = null,
+ modifier: Modifier = Modifier
+) {
+ Column(modifier) {
+ Stack(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .gravity(Alignment.CenterHorizontally)
+ ) {
+ if (podcastImageUrl != null) {
+ CoilImage(
+ data = podcastImageUrl,
+ contentScale = ContentScale.Crop,
+ loading = { /* TODO do something better here */ },
+ modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.medium)
+ )
+ }
+
+ ProvideEmphasis(EmphasisAmbient.current.high) {
+ ToggleFollowPodcastIconButton(
+ onClick = onToggleFollowClicked,
+ isFollowed = isFollowed,
+ modifier = Modifier.gravity(Alignment.BottomEnd)
+ )
+ }
+ }
+
+ ProvideEmphasis(EmphasisAmbient.current.high) {
+ Text(
+ text = podcastTitle,
+ style = MaterialTheme.typography.caption,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 8.dp).weight(1f)
+ )
+ }
+ }
+}
+
+private val MediumDateFormatter by lazy {
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
+}
+
+@Preview
+@Composable
+fun PreviewEpisodeListItem() {
+ JetcasterTheme {
+ EpisodeListItem(
+ episode = PreviewEpisodes[0],
+ podcast = PreviewPodcasts[0],
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+private sealed class PodcastCategoryItem {
+ data class EpisodeItem(val episode: Episode, val podcast: Podcast) : PodcastCategoryItem()
+ data class TopPodcastsItem(val podcasts: List) : PodcastCategoryItem()
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PodcastCategoryViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PodcastCategoryViewModel.kt
new file mode 100644
index 0000000000..7b0659b1ce
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PodcastCategoryViewModel.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.ui.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.jetcaster.Graph
+import com.example.jetcaster.data.CategoryStore
+import com.example.jetcaster.data.EpisodeToPodcast
+import com.example.jetcaster.data.PodcastStore
+import com.example.jetcaster.data.PodcastWithExtraInfo
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+class PodcastCategoryViewModel(
+ private val categoryId: Long,
+ private val categoryStore: CategoryStore = Graph.categoryStore,
+ private val podcastStore: PodcastStore = Graph.podcastStore
+) : ViewModel() {
+ private val _state = MutableStateFlow(PodcastCategoryViewState())
+
+ val state: StateFlow
+ get() = _state
+
+ init {
+ viewModelScope.launch {
+ val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount(
+ categoryId,
+ limit = 10
+ )
+
+ val episodesFlow = categoryStore.episodesFromPodcastsInCategory(
+ categoryId,
+ limit = 20
+ )
+
+ // Combine our flows and collect them into the view state StateFlow
+ combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes ->
+ PodcastCategoryViewState(
+ topPodcasts = topPodcasts,
+ episodes = episodes
+ )
+ }.collect { _state.value = it }
+ }
+ }
+
+ fun onTogglePodcastFollowed(podcastUri: String) {
+ viewModelScope.launch {
+ podcastStore.togglePodcastFollowed(podcastUri)
+ }
+ }
+}
+
+data class PodcastCategoryViewState(
+ val topPodcasts: List = emptyList(),
+ val episodes: List = emptyList()
+)
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
new file mode 100644
index 0000000000..56df6a841e
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.ui.home
+
+import com.example.jetcaster.data.Category
+import com.example.jetcaster.data.Episode
+import com.example.jetcaster.data.Podcast
+import com.example.jetcaster.data.PodcastWithExtraInfo
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+
+val PreviewCategories = listOf(
+ Category(name = "Crime"),
+ Category(name = "News"),
+ Category(name = "Comedy")
+)
+
+val PreviewPodcasts = listOf(
+ Podcast(
+ uri = "fakeUri://podcast/1",
+ title = "Android Developers Backstage",
+ author = "Android Developers"
+ ),
+ Podcast(
+ uri = "fakeUri://podcast/2",
+ title = "Google Developers podcast",
+ author = "Google Developers"
+ )
+)
+
+val PreviewPodcastsWithExtraInfo = PreviewPodcasts.mapIndexed { index, podcast ->
+ PodcastWithExtraInfo().apply {
+ this.podcast = podcast
+ this.lastEpisodeDate = OffsetDateTime.now()
+ this.isFollowed = index % 2 == 0
+ }
+}
+
+val PreviewEpisodes = listOf(
+ Episode(
+ uri = "fakeUri://episode/1",
+ podcastUri = PreviewPodcasts[0].uri,
+ title = "Episode 140: Bubbles!",
+ summary = "In this episode, Romain, Chet and Tor talked with Mady Melor and Artur Tsurkan from the System UI team about... Bubbles!",
+ published = OffsetDateTime.of(2020, 6, 2, 9, 27, 0, 0, ZoneOffset.of("PST"))
+ )
+)
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
new file mode 100644
index 0000000000..e107dbbccd
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt
@@ -0,0 +1,251 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.ui.home.discover
+
+import androidx.animation.FloatPropKey
+import androidx.animation.LinearEasing
+import androidx.animation.LinearOutSlowInEasing
+import androidx.animation.TransitionDefinition
+import androidx.animation.transitionDefinition
+import androidx.animation.tween
+import androidx.compose.Composable
+import androidx.compose.collectAsState
+import androidx.compose.emptyContent
+import androidx.compose.getValue
+import androidx.compose.onCommit
+import androidx.compose.remember
+import androidx.compose.setValue
+import androidx.compose.state
+import androidx.ui.core.DensityAmbient
+import androidx.ui.core.Modifier
+import androidx.ui.core.drawLayer
+import androidx.ui.foundation.Border
+import androidx.ui.foundation.Text
+import androidx.ui.foundation.contentColor
+import androidx.ui.graphics.Color
+import androidx.ui.layout.Column
+import androidx.ui.layout.Spacer
+import androidx.ui.layout.fillMaxSize
+import androidx.ui.layout.fillMaxWidth
+import androidx.ui.layout.padding
+import androidx.ui.layout.preferredHeight
+import androidx.ui.material.EmphasisAmbient
+import androidx.ui.material.MaterialTheme
+import androidx.ui.material.Surface
+import androidx.ui.material.Tab
+import androidx.ui.material.TabRow
+import androidx.ui.unit.dp
+import androidx.ui.viewmodel.viewModel
+import com.example.jetcaster.data.Category
+import com.example.jetcaster.ui.home.PodcastCategory
+import com.example.jetcaster.util.ItemSwitcher
+import com.example.jetcaster.util.ItemTransitionState
+
+@Composable
+fun Discover(
+ modifier: Modifier = Modifier
+) {
+ val viewModel: DiscoverViewModel = viewModel()
+ val viewState by viewModel.state.collectAsState()
+
+ val selectedCategory = viewState.selectedCategory
+
+ if (viewState.categories.isNotEmpty() && selectedCategory != null) {
+ Column(modifier) {
+ Spacer(Modifier.preferredHeight(8.dp))
+
+ // We need to keep track of the previously selected category, to determine the
+ // change direction below for the transition
+ var previousSelectedCategory by state { null }
+
+ PodcastCategoryTabs(
+ categories = viewState.categories,
+ selectedCategory = selectedCategory,
+ onCategorySelected = viewModel::onCategorySelected,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(Modifier.preferredHeight(8.dp))
+
+ // We need to reverse the transition if the new category is to the left/start
+ // of the previous category
+ val reverseTransition = previousSelectedCategory?.let { p ->
+ viewState.categories.indexOf(selectedCategory) < viewState.categories.indexOf(p)
+ } ?: false
+ val transitionOffset = with(DensityAmbient.current) { 16.dp.toPx() }
+
+ ItemSwitcher(
+ current = selectedCategory,
+ transitionDefinition = getChoiceChipTransitionDefinition(
+ reverse = reverseTransition,
+ offsetPx = transitionOffset
+ ),
+ modifier = Modifier.fillMaxWidth()
+ .weight(1f)
+ ) { category, transitionState ->
+ /**
+ * TODO, need to think about how this will scroll within the outer VerticalScroller
+ */
+ PodcastCategory(
+ categoryId = category.id,
+ modifier = Modifier.fillMaxSize()
+ .drawLayer(
+ translationX = transitionState[Offset],
+ alpha = transitionState[Alpha]
+ )
+ )
+ }
+
+ onCommit(selectedCategory) {
+ // Update our tracking of the previously selected category
+ previousSelectedCategory = selectedCategory
+ }
+ }
+ } else {
+ // TODO: empty state
+ }
+}
+
+@Composable
+private fun PodcastCategoryTabs(
+ categories: List,
+ selectedCategory: Category,
+ onCategorySelected: (Category) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val selectedIndex = categories.indexOfFirst { it == selectedCategory }
+ TabRow(
+ items = categories,
+ selectedIndex = selectedIndex,
+ scrollable = true,
+ divider = emptyContent(), /* Disable the built-in divider */
+ indicatorContainer = { _ -> },
+ modifier = modifier
+ ) { index, category ->
+ Tab(
+ selected = index == selectedIndex,
+ onSelected = { onCategorySelected(category) }
+ ) {
+ ChoiceChipContent(
+ text = category.name,
+ selected = index == selectedIndex,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun ChoiceChipContent(
+ text: String,
+ selected: Boolean,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ color = when {
+ selected -> MaterialTheme.colors.primary.copy(alpha = 0.08f)
+ else -> Color.Transparent
+ },
+ contentColor = when {
+ selected -> MaterialTheme.colors.primary
+ else -> EmphasisAmbient.current.high.applyEmphasis(contentColor())
+ },
+ shape = MaterialTheme.shapes.small,
+ border = Border(
+ size = 1.dp,
+ color = when {
+ selected -> MaterialTheme.colors.primary
+ else -> EmphasisAmbient.current.disabled.applyEmphasis(contentColor())
+ }
+ ),
+ modifier = modifier
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.subtitle2,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+}
+
+private val Alpha = FloatPropKey()
+private val Offset = FloatPropKey()
+
+@Composable
+private fun getChoiceChipTransitionDefinition(
+ duration: Int = 183,
+ offsetPx: Float,
+ reverse: Boolean = false
+): TransitionDefinition = remember(reverse, offsetPx, duration) {
+ transitionDefinition {
+ state(ItemTransitionState.Visible) {
+ this[Alpha] = 1f
+ this[Offset] = 0f
+ }
+ state(ItemTransitionState.BecomingVisible) {
+ this[Alpha] = 0f
+ this[Offset] = if (reverse) -offsetPx else offsetPx
+ }
+ state(ItemTransitionState.BecomingNotVisible) {
+ this[Alpha] = 0f
+ this[Offset] = if (reverse) offsetPx else -offsetPx
+ }
+
+ val halfDuration = duration / 2
+
+ transition(
+ fromState = ItemTransitionState.BecomingVisible,
+ toState = ItemTransitionState.Visible
+ ) {
+ // TODO: look at whether this can be implemented using `spring` to enable
+ // interruptions, etc
+ Alpha using tween(
+ durationMillis = halfDuration,
+ delayMillis = halfDuration,
+ easing = LinearEasing
+ )
+ Offset using tween(
+ durationMillis = halfDuration,
+ delayMillis = halfDuration,
+ easing = LinearOutSlowInEasing
+ )
+ }
+
+ transition(
+ fromState = ItemTransitionState.Visible,
+ toState = ItemTransitionState.BecomingNotVisible
+ ) {
+ Alpha using tween(
+ durationMillis = halfDuration,
+ easing = LinearEasing,
+ delayMillis = DelayForContentToLoad
+ )
+ Offset using tween(
+ durationMillis = halfDuration,
+ easing = LinearOutSlowInEasing,
+ delayMillis = DelayForContentToLoad
+ )
+ }
+ }
+}
+
+/**
+ * This is a hack. Compose currently has no concept of delayed transitions, something akin to
+ * Fragment postponing of transitions while content loads. To workaround that for now, we
+ * introduce an initial hardcoded delay of 24ms.
+ */
+private const val DelayForContentToLoad = 24
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt
new file mode 100644
index 0000000000..d21d833927
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.ui.home.discover
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.jetcaster.Graph
+import com.example.jetcaster.data.Category
+import com.example.jetcaster.data.CategoryStore
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+class DiscoverViewModel(
+ private val categoryStore: CategoryStore = Graph.categoryStore
+) : ViewModel() {
+ // Holds our currently selected category
+ private val _selectedCategory = MutableStateFlow(null)
+
+ // Holds our view state which the UI collects via [state]
+ private val _state = MutableStateFlow(DiscoverViewState())
+
+ val state: StateFlow
+ get() = _state
+
+ init {
+ viewModelScope.launch {
+ // Combines the latest value from each of the flows, allowing us to generate a
+ // view state instance which only contains the latest values.
+ combine(
+ 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
+ )
+ }.collect { _state.value = it }
+ }
+ }
+
+ fun onCategorySelected(category: Category) {
+ _selectedCategory.value = category
+ }
+}
+
+data class DiscoverViewState(
+ val categories: List = emptyList(),
+ val selectedCategory: Category? = null
+)
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
new file mode 100644
index 0000000000..eac8396671
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.Composable
+import androidx.ui.graphics.Color
+import androidx.ui.graphics.compositeOver
+import androidx.ui.material.ColorPalette
+import androidx.ui.material.darkColorPalette
+
+/**
+ * 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 ColorPalette.compositedOnSurface(alpha: Float): Color {
+ return onSurface.copy(alpha = alpha).compositeOver(surface)
+}
+
+val Yellow800 = Color(0xFFF29F05)
+val Red300 = Color(0xFFEA6D7E)
+
+val Colors = darkColorPalette(
+ 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/Keylines.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt
new file mode 100644
index 0000000000..a9c189a420
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.ui.unit.dp
+
+val Keyline1 = 24.dp
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt
new file mode 100644
index 0000000000..4ad5818e71
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.ui.foundation.shape.corner.RoundedCornerShape
+import androidx.ui.material.Shapes
+import androidx.ui.unit.dp
+
+val Shapes = Shapes(
+ small = RoundedCornerShape(percent = 50),
+ medium = RoundedCornerShape(size = 8.dp),
+ large = RoundedCornerShape(size = 0.dp)
+)
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
new file mode 100644
index 0000000000..be1592cdf0
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.Composable
+import androidx.ui.material.MaterialTheme
+
+@Composable
+fun JetcasterTheme(
+ content: @Composable () -> Unit
+) {
+ MaterialTheme(
+ colors = Colors,
+ typography = Typography,
+ shapes = Shapes,
+ 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
new file mode 100644
index 0000000000..a75ec60a59
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt
@@ -0,0 +1,122 @@
+/*
+ * 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.ui.material.Typography
+import androidx.ui.text.TextStyle
+import androidx.ui.text.font.FontWeight
+import androidx.ui.text.font.font
+import androidx.ui.text.font.fontFamily
+import androidx.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 Typography = 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
new file mode 100644
index 0000000000..151cf0fc84
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.util
+
+import androidx.compose.Composable
+import androidx.ui.core.Modifier
+import androidx.ui.foundation.Icon
+import androidx.ui.foundation.drawBackground
+import androidx.ui.layout.padding
+import androidx.ui.material.IconButton
+import androidx.ui.material.MaterialTheme
+import androidx.ui.material.icons.Icons
+import androidx.ui.material.icons.filled.Add
+import androidx.ui.material.icons.filled.Check
+import androidx.ui.unit.dp
+
+@Composable
+fun ToggleFollowPodcastIconButton(
+ isFollowed: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = modifier
+ ) {
+ Icon(
+ // TODO: think about animating these icons
+ asset = when {
+ isFollowed -> Icons.Default.Check
+ else -> Icons.Default.Add
+ },
+ modifier = Modifier
+ .drawBackground(
+ color = MaterialTheme.colors.surface,
+ shape = MaterialTheme.shapes.small
+ )
+ .padding(4.dp)
+ )
+ }
+}
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
new file mode 100644
index 0000000000..afde823313
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.util
+
+import androidx.annotation.FloatRange
+import androidx.compose.Composable
+import androidx.compose.getValue
+import androidx.compose.remember
+import androidx.compose.setValue
+import androidx.compose.state
+import androidx.ui.core.Modifier
+import androidx.ui.core.composed
+import androidx.ui.core.drawWithContent
+import androidx.ui.graphics.Color
+import androidx.ui.graphics.VerticalGradient
+import kotlin.math.pow
+
+/**
+ * Draws a vertical gradient scrim in the foreground.
+ *
+ * @param color The color of the gradient scrim.
+ * @param startYPercentage The start y value, in percentage of the layout's height (0f to 1f)
+ * @param endYPercentage The end y value, in percentage of the layout's height (0f to 1f)
+ * @param decay The exponential decay to apply to the gradient. Defaults to `1.0f` which is
+ * a linear gradient.
+ * @param numStops The number of color stops to draw in the gradient. Higher numbers result in
+ * the higher visual quality at the cost of draw performance. Defaults to `16`.
+ */
+@Composable
+fun Modifier.verticalGradientScrim(
+ color: Color,
+ @FloatRange(from = 0.0, to = 1.0) startYPercentage: Float = 0f,
+ @FloatRange(from = 0.0, to = 1.0) endYPercentage: Float = 1f,
+ decay: Float = 1.0f,
+ numStops: Int = 16
+): Modifier = composed {
+ val colors = remember(color, numStops) {
+ val baseAlpha = color.alpha
+ List(numStops) { i ->
+ val x = i * 1f / (numStops - 1)
+ val opacity = x.pow(decay)
+ color.copy(alpha = baseAlpha * opacity)
+ }
+ }
+
+ var height by state { 0f }
+ val brush = remember(color, numStops, startYPercentage, endYPercentage, height) {
+ VerticalGradient(
+ colors = colors,
+ startY = height * startYPercentage,
+ endY = height * endYPercentage
+ )
+ }
+
+ drawWithContent {
+ height = size.height
+
+ drawContent()
+ drawRect(brush = brush)
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Insets.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Insets.kt
new file mode 100644
index 0000000000..6613966f02
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Insets.kt
@@ -0,0 +1,417 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.example.jetcaster.util
+
+import android.view.View
+import androidx.compose.Composable
+import androidx.compose.Providers
+import androidx.compose.Stable
+import androidx.compose.getValue
+import androidx.compose.mutableStateOf
+import androidx.compose.onCommit
+import androidx.compose.remember
+import androidx.compose.setValue
+import androidx.compose.staticAmbientOf
+import androidx.core.view.ViewCompat
+import androidx.ui.core.Constraints
+import androidx.ui.core.IntrinsicMeasurable
+import androidx.ui.core.IntrinsicMeasureScope
+import androidx.ui.core.LayoutDirection
+import androidx.ui.core.LayoutModifier
+import androidx.ui.core.Measurable
+import androidx.ui.core.MeasureScope
+import androidx.ui.core.Modifier
+import androidx.ui.core.ViewAmbient
+import androidx.ui.core.composed
+import androidx.ui.core.offset
+import androidx.ui.layout.height
+import androidx.ui.layout.width
+import kotlin.math.min
+
+/**
+ * Main holder of our inset values.
+ */
+@Stable
+class DisplayInsets {
+ val systemBars = Insets()
+ val systemGestures = Insets()
+}
+
+@Stable
+class Insets {
+ var left by mutableStateOf(0)
+ internal set
+ var top by mutableStateOf(0)
+ internal set
+ var right by mutableStateOf(0)
+ internal set
+ var bottom by mutableStateOf(0)
+ internal set
+
+ /**
+ * TODO: doesn't currently work
+ */
+ var visible by mutableStateOf(true)
+ internal set
+}
+
+val InsetsAmbient = staticAmbientOf()
+
+@Composable
+fun ProvideDisplayInsets(content: @Composable () -> Unit) {
+ val view = ViewAmbient.current
+
+ val displayInsets = remember { DisplayInsets() }
+
+ onCommit(view) {
+ ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets ->
+ displayInsets.systemBars.updateFrom(windowInsets.systemWindowInsets)
+ displayInsets.systemGestures.updateFrom(windowInsets.systemGestureInsets)
+
+ // Return the unconsumed insets
+ windowInsets
+ }
+
+ // Add an OnAttachStateChangeListener to request an inset pass each time we're attached
+ // to the window
+ val attachListener = object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(v: View) {
+ v.requestApplyInsets()
+ }
+
+ override fun onViewDetachedFromWindow(v: View) = Unit
+ }
+ view.addOnAttachStateChangeListener(attachListener)
+
+ if (view.isAttachedToWindow) {
+ // If the view is already attached, we can request an inset pass now
+ view.requestApplyInsets()
+ }
+
+ onDispose {
+ view.removeOnAttachStateChangeListener(attachListener)
+ }
+ }
+
+ Providers(InsetsAmbient provides displayInsets) {
+ content()
+ }
+}
+
+/**
+ * Selectively apply additional space which matches the width/height of any system bars present
+ * on the respective edges of the screen.
+ *
+ * @param enabled Whether to apply padding using the system bar dimensions on the respective edges.
+ * Defaults to `true`.
+ */
+fun Modifier.systemBarsPadding(enabled: Boolean = true) = composed {
+ insetsPadding(
+ insets = InsetsAmbient.current.systemBars,
+ left = enabled,
+ top = enabled,
+ right = enabled,
+ bottom = enabled
+ )
+}
+
+/**
+ * Apply additional space which matches the height of the status height along the top edge
+ * of the content.
+ */
+fun Modifier.statusBarPadding() = composed {
+ insetsPadding(insets = InsetsAmbient.current.systemBars, top = true)
+}
+
+/**
+ * Apply additional space which matches the height of the navigation bar height
+ * along the [bottom] edge of the content, and additional space which matches the width of
+ * the navigation bar on the respective [left] and [right] edges.
+ *
+ * @param bottom Whether to apply padding to the bottom edge, which matches the navigation bar
+ * height (if present) at the bottom edge of the screen. Defaults to `true`.
+ * @param left Whether to apply padding to the left edge, which matches the navigation bar width
+ * (if present) on the left edge of the screen. Defaults to `true`.
+ * @param right Whether to apply padding to the right edge, which matches the navigation bar width
+ * (if present) on the right edge of the screen. Defaults to `true`.
+ */
+fun Modifier.navigationBarPadding(
+ bottom: Boolean = true,
+ left: Boolean = true,
+ right: Boolean = true
+) = composed {
+ insetsPadding(
+ insets = InsetsAmbient.current.systemBars,
+ left = left,
+ right = right,
+ bottom = bottom
+ )
+}
+
+/**
+ * Updates our mutable state backed [Insets] from an Android system insets.
+ */
+private fun Insets.updateFrom(insets: androidx.core.graphics.Insets) {
+ left = insets.left
+ top = insets.top
+ right = insets.right
+ bottom = insets.bottom
+}
+
+/**
+ * Declare the height of the content to match the height of the status bar exactly.
+ *
+ * This is very handy when used with `Spacer` to push content below the status bar:
+ * ```
+ * Column {
+ * Spacer(Modifier.statusBarHeight())
+ *
+ * // Content to be drawn below status bar (y-axis)
+ * }
+ * ```
+ *
+ * It's also useful when used to draw a scrim which matches the status bar:
+ * ```
+ * Spacer(
+ * Modifier.statusBarHeight()
+ * .fillMaxWidth()
+ * .drawBackground(MaterialTheme.colors.background.copy(alpha = 0.3f)
+ * )
+ * ```
+ *
+ * Internally this uses [Modifier.height] so has the same characteristics with regards to incoming
+ * layout constraints.
+ */
+fun Modifier.statusBarHeight() = composed {
+ // TODO: Move to Android 11 WindowInsets APIs when they land in AndroidX.
+ // It currently assumes that status bar == top which is probably fine, but doesn't work
+ // in multi-window, etc.
+ InsetsSizeModifier(insets = InsetsAmbient.current.systemBars, heightSide = VerticalSide.Top)
+}
+
+/**
+ * Declare the preferred height of the content to match the height of the navigation bar when present at the bottom of the screen.
+ *
+ * This is very handy when used with `Spacer` to push content below the navigation bar:
+ * ```
+ * Column {
+ * // Content to be drawn above status bar (y-axis)
+ * Spacer(Modifier.navigationBarHeight())
+ * }
+ * ```
+ *
+ * It's also useful when used to draw a scrim which matches the navigation bar:
+ * ```
+ * Spacer(
+ * Modifier.navigationBarHeight()
+ * .fillMaxWidth()
+ * .drawBackground(MaterialTheme.colors.background.copy(alpha = 0.3f)
+ * )
+ * ```
+ *
+ * Internally this uses [Modifier.height] so has the same characteristics with regards to incoming
+ * layout constraints.
+ */
+fun Modifier.navigationBarHeight() = composed {
+ // TODO: Move to Android 11 WindowInsets APIs when they land in AndroidX.
+ // It currently assumes that nav bar == bottom, which is wrong in landscape.
+ // It also doesn't handle the IME correctly.
+ InsetsSizeModifier(insets = InsetsAmbient.current.systemBars, heightSide = VerticalSide.Bottom)
+}
+
+enum class HorizontalSide { Left, Right }
+enum class VerticalSide { Top, Bottom }
+
+/**
+ * Declare the preferred width of the content to match the width of the navigation bar,
+ * on the given [side].
+ *
+ * This is very handy when used with `Spacer` to push content inside from any vertical
+ * navigation bars (typically when the device is in landscape):
+ * ```
+ * Row {
+ * Spacer(Modifier.navigationBarWidth(HorizontalSide.Left))
+ *
+ * // Content to be inside the navigation bars (x-axis)
+ *
+ * Spacer(Modifier.navigationBarWidth(HorizontalSide.Right))
+ * }
+ * ```
+ *
+ * It's also useful when used to draw a scrim which matches the navigation bar:
+ * ```
+ * Spacer(
+ * Modifier.navigationBarWidth(HorizontalSide.Left)
+ * .fillMaxHeight()
+ * .drawBackground(MaterialTheme.colors.background.copy(alpha = 0.3f)
+ * )
+ * ```
+ *
+ * Internally this uses [Modifier.width] so has the same characteristics with regards to incoming
+ * layout constraints.
+ *
+ * @param side The navigation bar side to use as the source for the width.
+ */
+fun Modifier.navigationBarWidth(side: HorizontalSide) = composed {
+ // TODO: Move to Android 11 WindowInsets APIs when they land in AndroidX.
+ // It currently assumes that nav bar == left/right
+ InsetsSizeModifier(insets = InsetsAmbient.current.systemBars, widthSide = side)
+}
+
+/**
+ * Allows conditional setting of [insets] on each dimension.
+ */
+private inline fun Modifier.insetsPadding(
+ insets: Insets,
+ left: Boolean = false,
+ top: Boolean = false,
+ right: Boolean = false,
+ bottom: Boolean = false
+) = this + InsetsPaddingModifier(insets, left, top, right, bottom)
+
+private data class InsetsPaddingModifier(
+ private val insets: Insets,
+ private val applyLeft: Boolean = false,
+ private val applyTop: Boolean = false,
+ private val applyRight: Boolean = false,
+ private val applyBottom: Boolean = false
+) : LayoutModifier {
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints,
+ layoutDirection: LayoutDirection
+ ): MeasureScope.MeasureResult {
+ val left = if (applyLeft) insets.left else 0
+ val top = if (applyTop) insets.top else 0
+ val right = if (applyRight) insets.right else 0
+ val bottom = if (applyBottom) insets.bottom else 0
+ val horizontal = left + right
+ val vertical = top + bottom
+
+ val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
+
+ val width = (placeable.width + horizontal)
+ .coerceIn(constraints.minWidth, constraints.maxWidth)
+ val height = (placeable.height + vertical)
+ .coerceIn(constraints.minHeight, constraints.maxHeight)
+ return layout(width, height) {
+ placeable.placeAbsolute(left, top)
+ }
+ }
+}
+
+private data class InsetsSizeModifier(
+ private val insets: Insets,
+ private val widthSide: HorizontalSide? = null,
+ private val heightSide: VerticalSide? = null
+) : LayoutModifier {
+ private val targetConstraints
+ get() = Constraints(
+ minWidth = when (widthSide) {
+ HorizontalSide.Left -> insets.left
+ HorizontalSide.Right -> insets.right
+ null -> 0
+ },
+ minHeight = when (heightSide) {
+ VerticalSide.Top -> insets.top
+ VerticalSide.Bottom -> insets.bottom
+ null -> 0
+ },
+ maxWidth = when (widthSide) {
+ HorizontalSide.Left -> insets.left
+ HorizontalSide.Right -> insets.right
+ null -> Constraints.Infinity
+ },
+ maxHeight = when (heightSide) {
+ VerticalSide.Top -> insets.top
+ VerticalSide.Bottom -> insets.bottom
+ null -> Constraints.Infinity
+ }
+ )
+
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints,
+ layoutDirection: LayoutDirection
+ ): MeasureScope.MeasureResult {
+ val wrappedConstraints = targetConstraints.let { targetConstraints ->
+ val resolvedMinWidth = if (widthSide != null) {
+ targetConstraints.minWidth
+ } else {
+ min(constraints.minWidth, targetConstraints.maxWidth)
+ }
+ val resolvedMaxWidth = if (widthSide != null) {
+ targetConstraints.maxWidth
+ } else {
+ min(constraints.maxWidth, targetConstraints.minWidth)
+ }
+ val resolvedMinHeight = if (heightSide != null) {
+ targetConstraints.minHeight
+ } else {
+ min(constraints.minHeight, targetConstraints.maxHeight)
+ }
+ val resolvedMaxHeight = if (heightSide != null) {
+ targetConstraints.maxHeight
+ } else {
+ min(constraints.maxHeight, targetConstraints.minHeight)
+ }
+ Constraints(resolvedMinWidth, resolvedMaxWidth, resolvedMinHeight, resolvedMaxHeight)
+ }
+ val placeable = measurable.measure(wrappedConstraints)
+ return layout(placeable.width, placeable.height) {
+ placeable.placeAbsolute(0, 0)
+ }
+ }
+
+ override fun IntrinsicMeasureScope.minIntrinsicWidth(
+ measurable: IntrinsicMeasurable,
+ height: Int,
+ layoutDirection: LayoutDirection
+ ) = measurable.minIntrinsicWidth(height, layoutDirection).let {
+ val constraints = targetConstraints
+ it.coerceIn(constraints.minWidth, constraints.maxWidth)
+ }
+
+ override fun IntrinsicMeasureScope.maxIntrinsicWidth(
+ measurable: IntrinsicMeasurable,
+ height: Int,
+ layoutDirection: LayoutDirection
+ ) = measurable.maxIntrinsicWidth(height, layoutDirection).let {
+ val constraints = targetConstraints
+ it.coerceIn(constraints.minWidth, constraints.maxWidth)
+ }
+
+ override fun IntrinsicMeasureScope.minIntrinsicHeight(
+ measurable: IntrinsicMeasurable,
+ width: Int,
+ layoutDirection: LayoutDirection
+ ) = measurable.minIntrinsicHeight(width, layoutDirection).let {
+ val constraints = targetConstraints
+ it.coerceIn(constraints.minHeight, constraints.maxHeight)
+ }
+
+ override fun IntrinsicMeasureScope.maxIntrinsicHeight(
+ measurable: IntrinsicMeasurable,
+ width: Int,
+ layoutDirection: LayoutDirection
+ ) = measurable.maxIntrinsicHeight(width, layoutDirection).let {
+ val constraints = targetConstraints
+ it.coerceIn(constraints.minHeight, constraints.maxHeight)
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/ItemSwitcher.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/ItemSwitcher.kt
new file mode 100644
index 0000000000..ef19e0930d
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/ItemSwitcher.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.util
+
+import androidx.animation.TransitionDefinition
+import androidx.animation.TransitionState
+import androidx.animation.createAnimation
+import androidx.compose.Composable
+import androidx.compose.invalidate
+import androidx.compose.key
+import androidx.compose.onCommit
+import androidx.compose.remember
+import androidx.ui.animation.asDisposableClock
+import androidx.ui.core.AnimationClockAmbient
+import androidx.ui.core.Modifier
+import androidx.ui.layout.Stack
+import androidx.ui.util.fastForEach
+
+/**
+ * [ItemSwitcher] allows to switch between two layouts with a transition defined by
+ * [transitionDefinition].
+ *
+ * @param current is a key representing your current layout state. Every time you change a key
+ * the animation will be triggered. The [content] called with the old key will be animated out while
+ * the [content] called with the new key will be animated in.
+ * @param transitionDefinition is a [TransitionDefinition] using [ItemTransitionState] as
+ * the state type.
+ * @param modifier Modifier to be applied to the animation container.
+ */
+@Composable
+fun ItemSwitcher(
+ current: T,
+ transitionDefinition: TransitionDefinition,
+ modifier: Modifier = Modifier,
+ content: @Composable (T, TransitionState) -> Unit
+) {
+ val state = remember { ItemTransitionInnerState() }
+
+ if (current != state.current) {
+ state.current = current
+ val keys = state.items.map { it.key }.toMutableList()
+ if (!keys.contains(current)) {
+ keys.add(current)
+ }
+ state.items.clear()
+
+ keys.mapTo(state.items) { key ->
+ ItemTransitionItem(key) { children ->
+ val clock = AnimationClockAmbient.current.asDisposableClock()
+ val visible = key == current
+
+ val anim = remember(clock, transitionDefinition) {
+ transitionDefinition.createAnimation(
+ clock = clock,
+ initState = when {
+ visible -> ItemTransitionState.BecomingVisible
+ else -> ItemTransitionState.Visible
+ }
+ )
+ }
+
+ onCommit(visible) {
+ anim.onStateChangeFinished = { _ ->
+ if (key == state.current) {
+ // leave only the current in the list
+ state.items.removeAll { it.key != state.current }
+ state.invalidate()
+ }
+ }
+ anim.onUpdate = { state.invalidate() }
+
+ val targetState = when {
+ visible -> ItemTransitionState.Visible
+ else -> ItemTransitionState.BecomingNotVisible
+ }
+ anim.toState(targetState)
+ }
+
+ children(anim)
+ }
+ }
+ }
+ Stack(modifier) {
+ state.invalidate = invalidate
+ state.items.fastForEach { (item, transition) ->
+ key(item) {
+ transition { transitionState ->
+ content(item, transitionState)
+ }
+ }
+ }
+ }
+}
+
+enum class ItemTransitionState {
+ Visible, BecomingNotVisible, BecomingVisible,
+}
+
+private class ItemTransitionInnerState {
+ // we use Any here as something which will not be equals to the real initial value
+ var current: Any? = Any()
+ var items = mutableListOf>()
+ var invalidate: () -> Unit = { }
+}
+
+private data class ItemTransitionItem(
+ val key: T,
+ val content: ItemTransitionContent
+)
+
+private typealias ItemTransitionContent = @Composable (children: @Composable (TransitionState) -> Unit) -> Unit
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt
new file mode 100644
index 0000000000..1908a01ff7
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.util
+
+import androidx.animation.AnimationClockObservable
+import androidx.animation.fling
+import androidx.compose.Composable
+import androidx.compose.Immutable
+import androidx.compose.getValue
+import androidx.compose.key
+import androidx.compose.mutableStateOf
+import androidx.compose.setValue
+import androidx.compose.state
+import androidx.compose.structuralEqualityPolicy
+import androidx.ui.animation.AnimatedFloatModel
+import androidx.ui.core.Layout
+import androidx.ui.core.Measurable
+import androidx.ui.core.Modifier
+import androidx.ui.core.ParentDataModifier
+import androidx.ui.core.drawWithContent
+import androidx.ui.foundation.Box
+import androidx.ui.foundation.ContentGravity
+import androidx.ui.foundation.gestures.DragDirection
+import androidx.ui.foundation.gestures.draggable
+import androidx.ui.graphics.drawscope.scale
+import androidx.ui.unit.Density
+import androidx.ui.util.lerp
+import kotlin.math.roundToInt
+
+/**
+ * This is a modified version of:
+ * https://gist.github.com/adamp/07d468f4bcfe632670f305ce3734f511
+ */
+
+class PagerState(
+ clock: AnimationClockObservable,
+ currentPage: Int = 0,
+ minPage: Int = 0,
+ maxPage: Int = 0
+) {
+ private var _minPage by mutableStateOf(minPage)
+ var minPage: Int
+ get() = _minPage
+ set(value) {
+ _minPage = value.coerceAtMost(_maxPage)
+ _currentPage = _currentPage.coerceIn(_minPage, _maxPage)
+ }
+
+ private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy())
+ var maxPage: Int
+ get() = _maxPage
+ set(value) {
+ _maxPage = value.coerceAtLeast(_minPage)
+ _currentPage = _currentPage.coerceIn(_minPage, maxPage)
+ }
+
+ private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage))
+ var currentPage: Int
+ get() = _currentPage
+ set(value) {
+ _currentPage = value.coerceIn(minPage, maxPage)
+ }
+
+ enum class SelectionState { Selected, Undecided }
+
+ var selectionState by mutableStateOf(SelectionState.Selected)
+
+ inline fun selectPage(block: PagerState.() -> R): R = try {
+ selectionState = SelectionState.Undecided
+ block()
+ } finally {
+ selectPage()
+ }
+
+ fun selectPage() {
+ currentPage -= currentPageOffset.roundToInt()
+ currentPageOffset = 0f
+ selectionState = SelectionState.Selected
+ }
+
+ private var _currentPageOffset = AnimatedFloatModel(0f, clock = clock).apply {
+ setBounds(-1f, 1f)
+ }
+ var currentPageOffset: Float
+ get() = _currentPageOffset.value
+ set(value) {
+ val max = if (currentPage == minPage) 0f else 1f
+ val min = if (currentPage == maxPage) 0f else -1f
+ _currentPageOffset.snapTo(value.coerceIn(min, max))
+ }
+
+ fun fling(velocity: Float) {
+ if (velocity < 0 && currentPage == maxPage) return
+ if (velocity > 0 && currentPage == minPage) return
+
+ _currentPageOffset.fling(velocity) { _, _, _ ->
+ selectPage()
+ }
+ }
+
+ override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " +
+ "currentPage=$currentPage, currentPageOffset=$currentPageOffset}"
+}
+
+@Immutable
+private data class PageData(val page: Int) : ParentDataModifier {
+ override fun Density.modifyParentData(parentData: Any?): Any? = this@PageData
+}
+
+private val Measurable.page: Int
+ get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this")
+
+@Composable
+fun Pager(
+ state: PagerState,
+ offscreenLimit: Int = 2,
+ modifier: Modifier = Modifier,
+ pageContent: @Composable PagerScope.() -> Unit
+) {
+ var pageSize by state { 0 }
+ Layout(
+ children = {
+ val minPage = (state.currentPage - offscreenLimit).coerceAtLeast(state.minPage)
+ val maxPage = (state.currentPage + offscreenLimit).coerceAtMost(state.maxPage)
+
+ for (page in minPage..maxPage) {
+ val pageData = PageData(page)
+ val scope = PagerScope(state, page)
+ key(pageData) {
+ Box(gravity = ContentGravity.Center, modifier = pageData) {
+ scope.pageContent()
+ }
+ }
+ }
+ },
+ modifier = modifier.draggable(
+ dragDirection = DragDirection.Horizontal,
+ onDragStarted = {
+ state.selectionState = PagerState.SelectionState.Undecided
+ },
+ onDragStopped = { velocity ->
+ // Velocity is in pixels per second, but we deal in percentage offsets, so we
+ // need to scale the velocity to match
+ state.fling(velocity / pageSize)
+ }
+ ) { dy ->
+ with(state) {
+ val pos = pageSize * currentPageOffset
+ val max = if (currentPage == minPage) 0 else pageSize * offscreenLimit
+ val min = if (currentPage == maxPage) 0 else -pageSize * offscreenLimit
+ val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat())
+ currentPageOffset = newPos / pageSize
+ }
+ dy
+ }
+ ) { measurables, constraints ->
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ val currentPage = state.currentPage
+ val offset = state.currentPageOffset
+ val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+ measurables
+ .map {
+ it.measure(childConstraints) to it.page
+ }
+ .forEach { (placeable, page) ->
+ // TODO: current this centers each page. We should investigate reading
+ // gravity modifiers on the child, or maybe as a param to Pager.
+ val xCenterOffset = (constraints.maxWidth - placeable.width) / 2
+ val yCenterOffset = (constraints.maxHeight - placeable.height) / 2
+
+ if (currentPage == page) {
+ pageSize = placeable.width
+ }
+
+ placeable.place(
+ x = xCenterOffset + ((page - (currentPage - offset)) * placeable.width).roundToInt(),
+ y = yCenterOffset
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Scope for [Pager] content.
+ */
+class PagerScope(
+ private val state: PagerState,
+ val page: Int
+) {
+ /**
+ * Returns the current selected page
+ */
+ val currentPage: Int
+ get() = state.currentPage
+
+ /**
+ * Returns the current selected page offset
+ */
+ val currentPageOffset: Float
+ get() = state.currentPageOffset
+
+ /**
+ * Returns the current selection state
+ */
+ val selectionState: PagerState.SelectionState
+ get() = state.selectionState
+
+ /**
+ * Modifier which scales pager items according to their offset position. Similar in effect
+ * to a carousel.
+ */
+ fun Modifier.scalePagerItems(
+ unselectedScale: Float
+ ): Modifier = Modifier.drawWithContent {
+ if (selectionState == PagerState.SelectionState.Selected) {
+ // If the pager is 'selected', it's stationary so we use a simple if check
+ if (page != currentPage) {
+ scale(
+ scaleX = unselectedScale,
+ scaleY = unselectedScale,
+ pivotX = center.x,
+ pivotY = center.y
+ ) {
+ this@drawWithContent.drawContent()
+ }
+ } else {
+ drawContent()
+ }
+ } else {
+ // Otherwise the pager is being scrolled, so we need to look at the swipe progress
+ // and interpolate between the sizes
+ val offsetForPage = page - currentPage + currentPageOffset
+
+ val scale = if (offsetForPage < 0) {
+ // If the page is to the left of the current page, we scale from min -> 1f
+ lerp(
+ start = unselectedScale,
+ stop = 1f,
+ fraction = (1f + offsetForPage).coerceIn(0f, 1f)
+ )
+ } else {
+ // If the page is to the right of the current page, we scale from 1f -> min
+ lerp(
+ start = 1f,
+ stop = unselectedScale,
+ fraction = offsetForPage.coerceIn(0f, 1f)
+ )
+ }
+ scale(scale, scale, center.x, center.y) {
+ this@drawWithContent.drawContent()
+ }
+ }
+ }
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Palette.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Palette.kt
new file mode 100644
index 0000000000..c061a8fcbb
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Palette.kt
@@ -0,0 +1,165 @@
+/*
+ * 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.Composable
+import androidx.compose.State
+import androidx.compose.getValue
+import androidx.compose.launchInComposition
+import androidx.compose.mutableStateOf
+import androidx.compose.remember
+import androidx.compose.setValue
+import androidx.core.graphics.drawable.toBitmap
+import androidx.palette.graphics.Palette
+import androidx.ui.animation.animate
+import androidx.ui.core.ContextAmbient
+import androidx.ui.core.Modifier
+import androidx.ui.foundation.Box
+import androidx.ui.graphics.Color
+import coil.Coil
+import coil.request.GetRequest
+import coil.request.SuccessResult
+import coil.size.Scale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * A custom [State] 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 cacheSize The size of the [LruCache] used to store recent results. Pass `0` to
+ * disable the cache.
+ */
+private class PaletteColorState(
+ private val context: Context,
+ private val defaultColor: Color,
+ cacheSize: Int = 12
+) : State {
+ private var colorValue by mutableStateOf(defaultColor)
+
+ private val cache = when {
+ cacheSize > 0 -> LruCache(cacheSize)
+ else -> null
+ }
+
+ override val value: Color
+ get() = colorValue
+
+ suspend fun calculateDominantColor(url: String) {
+ val cached = cache?.get(url)
+ colorValue = when {
+ cached != null -> cached
+ else -> {
+ calculateDominantColorInImage(
+ context = context,
+ imageUrl = url,
+ fallbackColor = defaultColor
+ ).also {
+ cache?.put(url, it)
+ }
+ }
+ }
+ }
+
+ /**
+ * Reset the color value to [defaultColor].
+ */
+ fun reset() {
+ colorValue = defaultColor
+ }
+}
+
+/**
+ * Draws a vertical gradient, using the dominant color in the image at [imageSourceUrl].
+ * The gradient is drawn with the opacity given in [opacity].
+ */
+@Composable
+fun DominantColorVerticalGradient(
+ imageSourceUrl: String?,
+ opacity: Float = 0.38f,
+ modifier: Modifier = Modifier
+) {
+ val context = ContextAmbient.current
+
+ val colorState = remember {
+ PaletteColorState(
+ context = context,
+ defaultColor = Color.Transparent
+ )
+ }
+
+ if (imageSourceUrl != null) {
+ launchInComposition(imageSourceUrl) {
+ colorState.calculateDominantColor(imageSourceUrl)
+ }
+ } else {
+ colorState.reset()
+ }
+
+ Box(
+ modifier = modifier.verticalGradientScrim(
+ color = animate(colorState.value.copy(alpha = opacity)),
+ startYPercentage = 1f,
+ endYPercentage = 0f
+ )
+ )
+}
+
+/**
+ * Fetches the given [imageUrl] with [Coil], then uses [Palette] to calculate the dominant color.
+ * If the image load fails, [fallbackColor] is returned.
+ */
+private suspend fun calculateDominantColorInImage(
+ context: Context,
+ imageUrl: String,
+ fallbackColor: Color = Color.Transparent
+): Color {
+ val r = GetRequest.Builder(context)
+ .data(imageUrl)
+ // We scale the image to fill 128px x 128px (i.e. min dimension == 128px)
+ .size(128)
+ .scale(Scale.FILL)
+ // Disable hardware bitmaps, since Palette uses Bitmap.getPixels()
+ .allowHardware(false)
+ .build()
+
+ val result = Coil.execute(r)
+
+ val bitmap = when (result) {
+ 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()
+ .generate()
+
+ palette.dominantSwatch?.let { Color(it.rgb) }
+ }
+ } ?: fallbackColor
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt
new file mode 100644
index 0000000000..6a729cf078
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.util
+
+import androidx.annotation.PluralsRes
+import androidx.compose.Composable
+import androidx.ui.core.ContextAmbient
+
+/**
+ * Load a quantity string resource.
+ *
+ * @param id the resource identifier
+ * @param quantity The number used to get the string for the current language's plural rules.
+ * @return the string data associated with the resource
+ */
+@Composable
+fun quantityStringResource(@PluralsRes id: Int, quantity: Int): String {
+ val context = ContextAmbient.current
+ return context.resources.getQuantityString(id, quantity)
+}
+
+/**
+ * Load a quantity string resource with formatting.
+ *
+ * @param id the resource identifier
+ * @param quantity The number used to get the string for the current language's plural rules.
+ * @param formatArgs the format arguments
+ * @return the string data associated with the resource
+ */
+@Composable
+fun quantityStringResource(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String {
+ val context = ContextAmbient.current
+ return context.resources.getQuantityString(id, quantity, *formatArgs)
+}
diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt
new file mode 100644
index 0000000000..10e8736688
--- /dev/null
+++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.jetcaster.util
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+
+/**
+ * Returns a [ViewModelProvider.Factory] which will return the result of [create] when it's
+ * [ViewModelProvider.Factory.create] function is called.
+ *
+ * If the created [ViewModel] does not match the requested class, an [IllegalArgumentException]
+ * exception is thrown.
+ */
+fun viewModelProviderFactoryOf(
+ create: () -> VM
+): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ val vm = create()
+ if (modelClass.isInstance(vm)) {
+ return vm as T
+ }
+ throw IllegalArgumentException("Can not create ViewModel for class: $modelClass")
+ }
+}
diff --git a/Jetcaster/app/src/main/res/drawable-nodpi/ic_text_logo.xml b/Jetcaster/app/src/main/res/drawable-nodpi/ic_text_logo.xml
new file mode 100644
index 0000000000..e422c1c25a
--- /dev/null
+++ b/Jetcaster/app/src/main/res/drawable-nodpi/ic_text_logo.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/Jetcaster/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Jetcaster/app/src/main/res/drawable-v26/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..797d79e6fd
--- /dev/null
+++ b/Jetcaster/app/src/main/res/drawable-v26/ic_launcher_foreground.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/Jetcaster/app/src/main/res/font/montserrat_light.ttf b/Jetcaster/app/src/main/res/font/montserrat_light.ttf
new file mode 100755
index 0000000000..990857de8e
Binary files /dev/null and b/Jetcaster/app/src/main/res/font/montserrat_light.ttf differ
diff --git a/Jetcaster/app/src/main/res/font/montserrat_medium.ttf b/Jetcaster/app/src/main/res/font/montserrat_medium.ttf
new file mode 100755
index 0000000000..6e079f6984
Binary files /dev/null and b/Jetcaster/app/src/main/res/font/montserrat_medium.ttf differ
diff --git a/Jetcaster/app/src/main/res/font/montserrat_regular.ttf b/Jetcaster/app/src/main/res/font/montserrat_regular.ttf
new file mode 100755
index 0000000000..8d443d5d56
Binary files /dev/null and b/Jetcaster/app/src/main/res/font/montserrat_regular.ttf differ
diff --git a/Jetcaster/app/src/main/res/font/montserrat_semibold.ttf b/Jetcaster/app/src/main/res/font/montserrat_semibold.ttf
new file mode 100755
index 0000000000..f8a43f2b20
Binary files /dev/null and b/Jetcaster/app/src/main/res/font/montserrat_semibold.ttf differ
diff --git a/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..3e3ac4da85
--- /dev/null
+++ b/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
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..f8a63ca513
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-xxxhdpi/ic_launcher.png b/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..4a5819490b
Binary files /dev/null and b/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/Jetcaster/app/src/main/res/values/ic_launcher_background.xml b/Jetcaster/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000000..9a14dccb5f
--- /dev/null
+++ b/Jetcaster/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ #D4DC54
+
\ No newline at end of file
diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..686536138b
--- /dev/null
+++ b/Jetcaster/app/src/main/res/values/strings.xml
@@ -0,0 +1,38 @@
+
+
+
+ Jetcaster
+ Your podcasts
+ Latest episodes
+
+ Your library
+ Discover
+
+ Updated a while ago
+
+ - Updated %d week ago
+ - Updated %d weeks ago
+
+
+ - Updated yesterday
+ - Updated %d days ago
+
+ Updated today
+
+ %1$s • %2$d mins
+
+
diff --git a/Jetcaster/app/src/main/res/values/themes.xml b/Jetcaster/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..8fb6a5decf
--- /dev/null
+++ b/Jetcaster/app/src/main/res/values/themes.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/Jetcaster/build.gradle b/Jetcaster/build.gradle
new file mode 100644
index 0000000000..a78e07fded
--- /dev/null
+++ b/Jetcaster/build.gradle
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+
+import com.example.jetcaster.buildsrc.Libs
+import com.example.jetcaster.buildsrc.Versions
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+buildscript {
+ repositories {
+ google()
+ jcenter()
+ }
+
+ dependencies {
+ classpath Libs.androidGradlePlugin
+ classpath Libs.Kotlin.gradlePlugin
+ }
+}
+
+plugins {
+ id 'com.diffplug.gradle.spotless' version '4.3.0'
+}
+
+subprojects {
+ repositories {
+ google()
+ mavenCentral()
+ jcenter()
+
+ if (Libs.AndroidX.Compose.version.endsWith("SNAPSHOT")) {
+ maven {
+ url "https://androidx.dev/snapshots/builds/${Libs.AndroidX.Compose.snapshot}/artifacts/ui/repository/"
+ }
+ }
+
+ maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
+ }
+
+ apply plugin: 'com.diffplug.gradle.spotless'
+ spotless {
+ kotlin {
+ target '**/*.kt'
+ targetExclude("$buildDir/**/*.kt")
+ targetExclude('bin/**/*.kt')
+
+ ktlint(Versions.ktlint)
+ licenseHeaderFile rootProject.file('spotless/copyright.kt')
+ }
+ }
+
+ tasks.withType(KotlinCompile).all {
+ kotlinOptions {
+ // Treat all Kotlin warnings as errors
+ allWarningsAsErrors = true
+
+ // Don't require parens on fun type annotations e.g. `@Composable~()~ () -> Unit`
+ // TODO: Remove when we move to Kotlin 1.4
+ freeCompilerArgs += '-XXLanguage:+NonParenthesizedAnnotationsOnFunctionalTypes'
+
+ freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
+
+ // Enable experimental coroutines APIs, including Flow
+ freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi'
+ freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview'
+ freeCompilerArgs += '-Xopt-in=kotlin.Experimental'
+
+ // Set JVM target to 1.8
+ jvmTarget = "1.8"
+
+ // Compose is based on the Kotlin 1.4 compiler, but we need to use the 1.3.x Kotlin
+ // library due to library compatibility, etc. Therefore we explicit set our apiVersion
+ // to 1.3 to fix any warnings. Binary dependencies (such as Compose) can continue to
+ // use 1.4 if built with that library.
+ // TODO: remove this once we move to Kotlin 1.4
+ apiVersion = "1.3"
+ }
+ }
+}
diff --git a/Jetcaster/buildSrc/build.gradle.kts b/Jetcaster/buildSrc/build.gradle.kts
new file mode 100644
index 0000000000..fc374f6ea3
--- /dev/null
+++ b/Jetcaster/buildSrc/build.gradle.kts
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+repositories {
+ jcenter()
+}
+
+plugins {
+ `kotlin-dsl`
+}
diff --git a/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt b/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt
new file mode 100644
index 0000000000..b0936e5cff
--- /dev/null
+++ b/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.buildsrc
+
+object Versions {
+ const val ktlint = "0.37.0"
+}
+
+object Libs {
+ const val androidGradlePlugin = "com.android.tools.build:gradle:4.2.0-alpha04"
+ const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9"
+
+ const val junit = "junit:junit:4.13"
+
+ const val material = "com.google.android.material:material:1.1.0"
+
+ object Accompanist {
+ private const val version = "0.1.7.ui-${AndroidX.Compose.snapshot}-SNAPSHOT"
+ const val mdcTheme = "dev.chrisbanes.accompanist:accompanist-mdc-theme:$version"
+ const val coil = "dev.chrisbanes.accompanist:accompanist-coil:$version"
+ }
+
+ object Kotlin {
+ private const val version = "1.3.72"
+ const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version"
+ const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version"
+ const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version"
+ }
+
+ object Coroutines {
+ private const val version = "1.3.7"
+ const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
+ const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
+ const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version"
+ }
+
+ object OkHttp {
+ private const val version = "4.7.2"
+ const val okhttp = "com.squareup.okhttp3:okhttp:$version"
+ const val logging = "com.squareup.okhttp3:logging-interceptor:$version"
+ }
+
+ object AndroidX {
+ const val appcompat = "androidx.appcompat:appcompat:1.2.0-rc01"
+ const val palette = "androidx.palette:palette:1.0.0"
+ const val coreKtx = "androidx.core:core-ktx:1.5.0-alpha01"
+
+ object Compose {
+ const val snapshot = "6658828"
+ const val version = "0.1.0-SNAPSHOT"
+
+ const val kotlinCompilerVersion = "1.3.70-dev-withExperimentalGoogleExtensions-20200424"
+
+ const val runtime = "androidx.compose:compose-runtime:$version"
+ const val core = "androidx.ui:ui-core:${version}"
+ const val foundation = "androidx.compose.foundation:foundation:${version}"
+ const val layout = "androidx.ui:ui-layout:${version}"
+ const val material = "androidx.compose.material:material:${version}"
+ const val materialIconsExtended = "androidx.compose.material:material-icons-extended:${version}"
+ const val tooling = "androidx.compose.tooling:tooling:${version}"
+ const val test = "androidx.compose.test:test-core:${version}"
+ }
+
+ object Test {
+ private const val version = "1.2.0"
+ const val core = "androidx.test:core:$version"
+ const val rules = "androidx.test:rules:$version"
+
+ object Ext {
+ private const val version = "1.1.2-rc01"
+ const val junit = "androidx.test.ext:junit-ktx:$version"
+ }
+
+ const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0"
+ }
+
+ object Room {
+ private const val version = "2.2.5"
+ const val runtime = "androidx.room:room-runtime:${version}"
+ const val ktx = "androidx.room:room-ktx:${version}"
+ const val compiler = "androidx.room:room-compiler:${version}"
+ }
+
+ object Lifecycle {
+ private const val version = "2.2.0"
+ const val extensions = "androidx.lifecycle:lifecycle-extensions:$version"
+ const val livedata = "androidx.lifecycle:lifecycle-livedata-ktx:$version"
+ const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version"
+ }
+ }
+
+ object Rome {
+ private const val version = "1.14.1"
+ val rome = "com.rometools:rome:$version"
+ val modules = "com.rometools:rome-modules:$version"
+ }
+}
diff --git a/Jetcaster/gradle.properties b/Jetcaster/gradle.properties
new file mode 100644
index 0000000000..94c6607b24
--- /dev/null
+++ b/Jetcaster/gradle.properties
@@ -0,0 +1,22 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m
+
+org.gradle.configureondemand=true
+org.gradle.caching=true
+org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
\ No newline at end of file
diff --git a/Jetcaster/gradle/wrapper/gradle-wrapper.jar b/Jetcaster/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..f6b961fd5a
Binary files /dev/null and b/Jetcaster/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/Jetcaster/gradle/wrapper/gradle-wrapper.properties b/Jetcaster/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..38c1d48d19
--- /dev/null
+++ b/Jetcaster/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip
diff --git a/Jetcaster/gradlew b/Jetcaster/gradlew
new file mode 100755
index 0000000000..cccdd3d517
--- /dev/null
+++ b/Jetcaster/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/Jetcaster/gradlew.bat b/Jetcaster/gradlew.bat
new file mode 100644
index 0000000000..f9553162f1
--- /dev/null
+++ b/Jetcaster/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/Jetcaster/settings.gradle b/Jetcaster/settings.gradle
new file mode 100644
index 0000000000..271819e4b2
--- /dev/null
+++ b/Jetcaster/settings.gradle
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+include ':app'
+rootProject.name = "Jetcaster"
\ No newline at end of file
diff --git a/Jetcaster/spotless/copyright.kt b/Jetcaster/spotless/copyright.kt
new file mode 100644
index 0000000000..806db0fb54
--- /dev/null
+++ b/Jetcaster/spotless/copyright.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright $YEAR The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+