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. + */ +