From 2c83710d2afc1f24417c2b27ff30fde3b9d5267d Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 4 Sep 2023 00:59:30 -0400 Subject: [PATCH 01/32] Make service-db KMP --- .../catchup/app/service/ServiceMediator.kt | 4 +-- build.gradle.kts | 1 + service-db/build.gradle.kts | 32 +++++++++++++------ .../sqldelight/catchup/service/db}/service.sq | 2 +- .../sqldelight/migrations/12.sqm | 0 5 files changed, 27 insertions(+), 12 deletions(-) rename service-db/src/{main/sqldelight/catchup/service => commonMain/sqldelight/catchup/service/db}/service.sq (99%) rename service-db/src/{main => commonMain}/sqldelight/migrations/12.sqm (100%) diff --git a/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt b/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt index c89cc9338..c6bc03052 100644 --- a/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt +++ b/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt @@ -6,7 +6,7 @@ import androidx.paging.LoadType.REFRESH import androidx.paging.PagingState import androidx.paging.RemoteMediator import catchup.app.data.lastUpdated -import catchup.service.CatchUpDbItem +import catchup.service.db.CatchUpDbItem import catchup.service.api.DataRequest import catchup.service.api.Service import catchup.service.db.CatchUpDatabase @@ -139,7 +139,7 @@ constructor( catchUpDatabase.transaction { if (loadType == REFRESH) { Timber.tag("ServiceMediator").d("Clearing DB $serviceId") - catchUpDatabase.serviceQueries.deleteItemByService(serviceId) + catchUpDatabase.serviceQueries.deleteItemsByService(serviceId) catchUpDatabase.serviceQueries.deleteOperationsByService(serviceId) catchUpDatabase.serviceQueries.deleteRemoteKeyByService(serviceId) } diff --git a/build.gradle.kts b/build.gradle.kts index 06694b7a2..f86a60709 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ plugins { alias(libs.plugins.retry) apply false alias(libs.plugins.bugsnag) apply false alias(libs.plugins.sortDependencies) apply false + alias(libs.plugins.sqldelight) apply false alias(libs.plugins.dependencyAnalysis) } diff --git a/service-db/build.gradle.kts b/service-db/build.gradle.kts index 4b4ccc231..2173c771e 100644 --- a/service-db/build.gradle.kts +++ b/service-db/build.gradle.kts @@ -13,14 +13,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { - alias(libs.plugins.sgp.base) + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) + alias(libs.plugins.sgp.base) alias(libs.plugins.sqldelight) } +kotlin { + // region KMP Targets + androidTarget() + jvm() + // endregion + + @OptIn(ExperimentalKotlinGradlePluginApi::class) targetHierarchy.default() + + sourceSets { + commonMain { + dependencies { + api(libs.kotlin.datetime) + api(libs.sqldelight.runtime) + + implementation(libs.kotlin.datetime) + } + } + } +} + android { namespace = "catchup.service.db" } slack { @@ -36,10 +57,3 @@ sqldelight { } } } - -dependencies { - api(libs.kotlin.datetime) - api(libs.sqldelight.runtime) - - implementation(libs.kotlin.datetime) -} diff --git a/service-db/src/main/sqldelight/catchup/service/service.sq b/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq similarity index 99% rename from service-db/src/main/sqldelight/catchup/service/service.sq rename to service-db/src/commonMain/sqldelight/catchup/service/db/service.sq index 1ebd922fa..1d72260d6 100644 --- a/service-db/src/main/sqldelight/catchup/service/service.sq +++ b/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq @@ -112,7 +112,7 @@ ORDER BY indexInResponse ASC -- Used for PagingSource LIMIT ? OFFSET ?; -deleteItemByService: +deleteItemsByService: DELETE FROM catchUpDbItem WHERE serviceId = ?; getNextIndexInService: diff --git a/service-db/src/main/sqldelight/migrations/12.sqm b/service-db/src/commonMain/sqldelight/migrations/12.sqm similarity index 100% rename from service-db/src/main/sqldelight/migrations/12.sqm rename to service-db/src/commonMain/sqldelight/migrations/12.sqm From f43dca9aa61c21d4f6c3f2c45d39fc3f56e6989c Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 4 Sep 2023 01:26:08 -0400 Subject: [PATCH 02/32] Create bookmarks:db project --- .../catchup/app/service/ServiceMediator.kt | 2 +- bookmarks/db/build.gradle.kts | 60 +++++++++++++++++++ .../sqldelight/catchup/bookmarks/bookmarks.sq | 24 ++++++++ service-db/build.gradle.kts | 2 - settings-all.gradle.kts | 2 +- 5 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 bookmarks/db/build.gradle.kts create mode 100644 bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/bookmarks.sq diff --git a/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt b/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt index c6bc03052..dca153ff0 100644 --- a/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt +++ b/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt @@ -6,10 +6,10 @@ import androidx.paging.LoadType.REFRESH import androidx.paging.PagingState import androidx.paging.RemoteMediator import catchup.app.data.lastUpdated -import catchup.service.db.CatchUpDbItem import catchup.service.api.DataRequest import catchup.service.api.Service import catchup.service.db.CatchUpDatabase +import catchup.service.db.CatchUpDbItem import com.apollographql.apollo3.exception.ApolloException import dagger.assisted.Assisted import dagger.assisted.AssistedFactory diff --git a/bookmarks/db/build.gradle.kts b/bookmarks/db/build.gradle.kts new file mode 100644 index 000000000..b2f9fed8a --- /dev/null +++ b/bookmarks/db/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018 Zac Sweers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.sgp.base) + alias(libs.plugins.sqldelight) +} + +kotlin { + // region KMP Targets + androidTarget() + jvm() + // endregion + + @OptIn(ExperimentalKotlinGradlePluginApi::class) targetHierarchy.default() + + sourceSets { + commonMain { + dependencies { + api(libs.kotlin.datetime) + api(libs.sqldelight.runtime) + } + } + } +} + +android { namespace = "catchup.bookmarks" } + +slack { + features { + dagger() + } +} + +sqldelight { + databases { + create("CatchUpDatabase") { + packageName.set("catchup.bookmarks") + dependency(projects.serviceDb) + schemaOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/databases")) + migrationOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/migrations")) + } + } +} diff --git a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/bookmarks.sq b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/bookmarks.sq new file mode 100644 index 000000000..8037e9717 --- /dev/null +++ b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/bookmarks.sq @@ -0,0 +1,24 @@ +import kotlinx.datetime.Instant; + +-- TODO capture service tint color? +CREATE TABLE IF NOT EXISTS bookmark ( + id INTEGER NOT NULL PRIMARY KEY, + timestamp INTEGER AS Instant NOT NULL +); + +insertBookmark: +INSERT OR REPLACE INTO bookmark (id, timestamp) VALUES (?, ?); + +bookmarks: +SELECT c.* +FROM catchUpDbItem AS c +JOIN ( + SELECT id + FROM bookmark + ORDER BY timestamp +-- Used for PagingSource + LIMIT :limit OFFSET :offset +) AS b ON c.id = b.id; + +deleteBookmark: +DELETE FROM bookmark WHERE id = ?; diff --git a/service-db/build.gradle.kts b/service-db/build.gradle.kts index 2173c771e..9d8756b20 100644 --- a/service-db/build.gradle.kts +++ b/service-db/build.gradle.kts @@ -35,8 +35,6 @@ kotlin { dependencies { api(libs.kotlin.datetime) api(libs.sqldelight.runtime) - - implementation(libs.kotlin.datetime) } } } diff --git a/settings-all.gradle.kts b/settings-all.gradle.kts index 0d668ba5d..d0126ec31 100644 --- a/settings-all.gradle.kts +++ b/settings-all.gradle.kts @@ -1,6 +1,7 @@ include( ":app", ":benchmark", + ":bookmarks:db", ":libraries:auth", ":libraries:base-ui", ":libraries:appconfig", @@ -14,7 +15,6 @@ include( ":libraries:gemoji:generator", ":libraries:flowbinding", ":libraries:kotlinutil", - ":libraries:newark", ":libraries:retrofitconverters", ":libraries:summarizer", ":libraries:tooling:spi-multibinds-validator", From 8cf570b44b48729032396f2ee9e791b2ccfec196 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 4 Sep 2023 15:14:30 -0400 Subject: [PATCH 03/32] First pass at BookmarkRepository --- .../catchup/app/service/ServiceMediator.kt | 1 + .../catchup/app/service/ServiceScreen.kt | 1 + bookmarks/build.gradle.kts | 51 ++++++++++ bookmarks/db/build.gradle.kts | 5 +- .../catchup/bookmarks/{ => db}/bookmarks.sq | 17 ++-- .../catchup/bookmarks/BookmarkRepository.kt | 92 +++++++++++++++++++ .../jvmMain/kotlin/catchup/bookmarks/di.kt | 12 +++ service-api/build.gradle.kts | 1 + .../kotlin/catchup/service/api}/DbMapping.kt | 8 +- settings-all.gradle.kts | 1 + 10 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 bookmarks/build.gradle.kts rename bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/{ => db}/bookmarks.sq (73%) create mode 100644 bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt create mode 100644 bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt rename {app/src/main/kotlin/catchup/app/service => service-api/src/commonMain/kotlin/catchup/service/api}/DbMapping.kt (92%) diff --git a/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt b/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt index dca153ff0..923e02622 100644 --- a/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt +++ b/app/src/main/kotlin/catchup/app/service/ServiceMediator.kt @@ -8,6 +8,7 @@ import androidx.paging.RemoteMediator import catchup.app.data.lastUpdated import catchup.service.api.DataRequest import catchup.service.api.Service +import catchup.service.api.toCatchUpDbItem import catchup.service.db.CatchUpDatabase import catchup.service.db.CatchUpDbItem import com.apollographql.apollo3.exception.ApolloException diff --git a/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt b/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt index e482e8a93..d1bfbcb27 100644 --- a/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt @@ -65,6 +65,7 @@ import catchup.pullrefresh.rememberPullRefreshState import catchup.service.api.CatchUpItem import catchup.service.api.ContentType import catchup.service.api.Service +import catchup.service.api.toCatchUpItem import catchup.service.db.CatchUpDatabase import catchup.summarizer.SummarizerScreen import com.slack.circuit.codegen.annotations.CircuitInject diff --git a/bookmarks/build.gradle.kts b/bookmarks/build.gradle.kts new file mode 100644 index 000000000..017a0aaf8 --- /dev/null +++ b/bookmarks/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018 Zac Sweers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.sgp.base) +} + +kotlin { + // region KMP Targets + androidTarget() + jvm() + // endregion + + @OptIn(ExperimentalKotlinGradlePluginApi::class) targetHierarchy.default() + + sourceSets { + commonMain { + dependencies { + api(libs.kotlin.datetime) + api(libs.sqldelight.runtime) + api(libs.sqldelight.coroutines) + implementation(projects.bookmarks.db) + implementation(projects.serviceApi) + } + } + } +} + +android { namespace = "catchup.bookmarks" } + +slack { + features { + dagger() + } +} \ No newline at end of file diff --git a/bookmarks/db/build.gradle.kts b/bookmarks/db/build.gradle.kts index b2f9fed8a..a79efbb6d 100644 --- a/bookmarks/db/build.gradle.kts +++ b/bookmarks/db/build.gradle.kts @@ -35,12 +35,13 @@ kotlin { dependencies { api(libs.kotlin.datetime) api(libs.sqldelight.runtime) + api(projects.serviceDb) } } } } -android { namespace = "catchup.bookmarks" } +android { namespace = "catchup.bookmarks.db" } slack { features { @@ -51,7 +52,7 @@ slack { sqldelight { databases { create("CatchUpDatabase") { - packageName.set("catchup.bookmarks") + packageName.set("catchup.bookmarks.db") dependency(projects.serviceDb) schemaOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/databases")) migrationOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/migrations")) diff --git a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/bookmarks.sq b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq similarity index 73% rename from bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/bookmarks.sq rename to bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq index 8037e9717..358cfbaaf 100644 --- a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/bookmarks.sq +++ b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq @@ -6,19 +6,24 @@ CREATE TABLE IF NOT EXISTS bookmark ( timestamp INTEGER AS Instant NOT NULL ); -insertBookmark: +addBookmark: INSERT OR REPLACE INTO bookmark (id, timestamp) VALUES (?, ?); -bookmarks: +bookmarkIds: +SELECT * FROM bookmark ORDER BY timestamp; + +removeBookmark: +DELETE FROM bookmark WHERE id = ?; + +bookmarkedItemsCount: +SELECT count(*) FROM bookmark; + +bookmarkedItems: SELECT c.* FROM catchUpDbItem AS c JOIN ( SELECT id FROM bookmark ORDER BY timestamp --- Used for PagingSource LIMIT :limit OFFSET :offset ) AS b ON c.id = b.id; - -deleteBookmark: -DELETE FROM bookmark WHERE id = ?; diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt new file mode 100644 index 000000000..d1dbab7c4 --- /dev/null +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -0,0 +1,92 @@ +package catchup.bookmarks + +import app.cash.sqldelight.Query +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import catchup.bookmarks.db.Bookmark +import catchup.bookmarks.db.CatchUpDatabase +import catchup.service.api.CatchUpItem +import catchup.service.api.toCatchUpItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock.System +import kotlinx.datetime.Instant + +interface BookmarkRepository { + fun addBookmark(id: Long, timestamp: Instant = System.now()) + + fun removeBookmark(id: Long) + + fun isBookmarked(id: Long): Flow + + // Exposed to create a PagingSource + fun bookmarksCountQuery(): Query + + fun bookmarksQuery(limit: Long, offset: Long): Query +} + +internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : BookmarkRepository { + private val scope = CoroutineScope(Dispatchers.IO) + + // Maintain an in-memory cache of all the bookmarks + private val bookmarks = MutableStateFlow(LinkedHashSet()) + + init { + scope.launch { + database.bookmarksQueries.bookmarkIds().asFlow().collect { query -> + // Preserve + bookmarks.emit(query.executeAsList().mapTo(LinkedHashSet(), Bookmark::id)) + } + } + } + + override fun addBookmark(id: Long, timestamp: Instant) { + scope.launch { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } + } + + override fun removeBookmark(id: Long) { + scope.launch { database.transaction { database.bookmarksQueries.removeBookmark(id) } } + } + + override fun isBookmarked(id: Long) = bookmarks.map { id in it } + + override fun bookmarksCountQuery() = database.bookmarksQueries.bookmarkedItemsCount() + + override fun bookmarksQuery(limit: Long, offset: Long): Query { + return database.bookmarksQueries.bookmarkedItems(limit, offset).map { it.toCatchUpItem() } + } +} + +fun Query.map(mapper: (T) -> R): Query { + @Suppress("UNCHECKED_CAST") return MappedQuery(this, mapper as (Any) -> Any) as Query +} + +private class MappedQuery(private val original: Query, private val newMapper: (Any) -> Any) : + Query(original.mapper) { + override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult { + return when (val delegate = original.execute(mapper)) { + is QueryResult.AsyncValue -> { + @Suppress("UNCHECKED_CAST") + QueryResult.AsyncValue { delegate.value?.let(newMapper) } as QueryResult + } + is QueryResult.Value -> { + val mappedValue = delegate.value?.let(newMapper) + @Suppress("UNCHECKED_CAST") + QueryResult.Value(mappedValue) as QueryResult + } + } + } + + override fun addListener(listener: Listener) { + original.addListener(listener) + } + + override fun removeListener(listener: Listener) { + original.removeListener(listener) + } +} diff --git a/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt b/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt new file mode 100644 index 000000000..7387799f7 --- /dev/null +++ b/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt @@ -0,0 +1,12 @@ +package catchup.bookmarks + +import catchup.bookmarks.db.CatchUpDatabase +import catchup.di.AppScope +import catchup.di.SingleIn +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class BookmarkRepositoryBinding @Inject constructor(private val database: CatchUpDatabase) : + BookmarkRepository by BookmarkRepositoryImpl(database) diff --git a/service-api/build.gradle.kts b/service-api/build.gradle.kts index 4225cb1ad..db52a8f73 100644 --- a/service-api/build.gradle.kts +++ b/service-api/build.gradle.kts @@ -41,6 +41,7 @@ kotlin { implementation(libs.androidx.annotations) implementation(libs.kotlin.coroutinesAndroid) implementation(libs.kotlin.datetime) + implementation(projects.serviceDb) } } with(getByName("androidMain")) { diff --git a/app/src/main/kotlin/catchup/app/service/DbMapping.kt b/service-api/src/commonMain/kotlin/catchup/service/api/DbMapping.kt similarity index 92% rename from app/src/main/kotlin/catchup/app/service/DbMapping.kt rename to service-api/src/commonMain/kotlin/catchup/service/api/DbMapping.kt index f6b950ac2..6f8152ffa 100644 --- a/app/src/main/kotlin/catchup/app/service/DbMapping.kt +++ b/service-api/src/commonMain/kotlin/catchup/service/api/DbMapping.kt @@ -1,11 +1,7 @@ -package catchup.app.service +package catchup.service.api -import catchup.service.CatchUpDbItem -import catchup.service.api.CatchUpItem -import catchup.service.api.ContentType -import catchup.service.api.ImageInfo -import catchup.service.api.Mark import catchup.service.api.Mark.MarkType +import catchup.service.db.CatchUpDbItem fun CatchUpItem.toCatchUpDbItem(): CatchUpDbItem { return CatchUpDbItem( diff --git a/settings-all.gradle.kts b/settings-all.gradle.kts index d0126ec31..0d3305894 100644 --- a/settings-all.gradle.kts +++ b/settings-all.gradle.kts @@ -1,6 +1,7 @@ include( ":app", ":benchmark", + ":bookmarks", ":bookmarks:db", ":libraries:auth", ":libraries:base-ui", From 6c9437991a3cce608a5ea097c237bbdeeefb22bd Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 9 Sep 2023 21:29:31 -0400 Subject: [PATCH 04/32] First pass integrating BookmarkIconScreen --- app/build.gradle.kts | 1 + .../catchup/app/data/CatchUpDatabaseModule.kt | 46 ++++++++---- .../catchup/app/service/BookmarkIconScreen.kt | 71 +++++++++++++++++++ .../catchup/app/service/FilledBookmark.kt | 31 ++++++++ .../catchup/app/service/OutlinedBookmark.kt | 38 ++++++++++ .../catchup/app/service/ServiceScreen.kt | 6 -- .../catchup/app/service/TextActionItem.kt | 33 +++++++++ .../catchup/app/service/TextServiceUi.kt | 38 ++++------ bookmarks/build.gradle.kts | 3 +- .../kotlin/catchup/bookmarks/di.kt | 12 ++++ .../catchup/bookmarks/BookmarkRepository.kt | 6 +- 11 files changed, 238 insertions(+), 47 deletions(-) create mode 100644 app/src/main/kotlin/catchup/app/service/BookmarkIconScreen.kt create mode 100644 app/src/main/kotlin/catchup/app/service/FilledBookmark.kt create mode 100644 app/src/main/kotlin/catchup/app/service/OutlinedBookmark.kt create mode 100644 app/src/main/kotlin/catchup/app/service/TextActionItem.kt create mode 100644 bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65be3fd89..e74e25514 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -554,6 +554,7 @@ dependencies { implementation(libs.sqldelight.runtime) implementation(libs.telephoto.zoomableImageCoil) implementation(libs.xmlutil.serialization) + implementation(projects.bookmarks) implementation(projects.libraries.appconfig) implementation(projects.libraries.auth) implementation(projects.libraries.baseUi) diff --git a/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt b/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt index ffb50ef1b..f56007e26 100644 --- a/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt +++ b/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt @@ -20,10 +20,12 @@ import app.cash.sqldelight.ColumnAdapter import app.cash.sqldelight.adapter.primitive.FloatColumnAdapter import app.cash.sqldelight.adapter.primitive.IntColumnAdapter import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import catchup.bookmarks.db.Bookmark +import catchup.bookmarks.db.CatchUpDatabase as BookmarksDatabase import catchup.di.AppScope import catchup.di.SingleIn -import catchup.service.CatchUpDbItem import catchup.service.db.CatchUpDatabase +import catchup.service.db.CatchUpDbItem import catchup.util.injection.qualifiers.ApplicationContext import com.squareup.anvil.annotations.ContributesTo import dagger.Module @@ -35,19 +37,39 @@ import kotlinx.datetime.Instant object CatchUpDatabaseModule { @Provides @SingleIn(AppScope::class) - fun provideCatchUpDatabase(@ApplicationContext context: Context): CatchUpDatabase = + fun provideCatchUpDatabase( + @ApplicationContext context: Context, + itemAdapter: CatchUpDbItem.Adapter + ): CatchUpDatabase = CatchUpDatabase( AndroidSqliteDriver(CatchUpDatabase.Schema, context, "catchUpItems.db"), - CatchUpDbItem.Adapter( - InstantColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - FloatColumnAdapter, - IntColumnAdapter, - ) + itemAdapter, + ) + + @Provides + @SingleIn(AppScope::class) + fun provideCatchUpDbItemAdapter(): CatchUpDbItem.Adapter = + CatchUpDbItem.Adapter( + InstantColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + FloatColumnAdapter, + IntColumnAdapter, + ) + + @Provides + @SingleIn(AppScope::class) + fun provideBookmarksDatabase( + @ApplicationContext context: Context, + itemAdapter: CatchUpDbItem.Adapter + ): BookmarksDatabase = + BookmarksDatabase( + AndroidSqliteDriver(BookmarksDatabase.Schema, context, "bookmarks.db"), + Bookmark.Adapter(InstantColumnAdapter), + itemAdapter, ) } diff --git a/app/src/main/kotlin/catchup/app/service/BookmarkIconScreen.kt b/app/src/main/kotlin/catchup/app/service/BookmarkIconScreen.kt new file mode 100644 index 000000000..6610b43e8 --- /dev/null +++ b/app/src/main/kotlin/catchup/app/service/BookmarkIconScreen.kt @@ -0,0 +1,71 @@ +package catchup.app.service + +import androidx.compose.material.icons.Icons.Filled +import androidx.compose.material.icons.Icons.Outlined +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import catchup.bookmarks.BookmarkRepository +import catchup.di.AppScope +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.parcelize.Parcelize + +@Parcelize +data class BookmarkIconScreen(val id: Long, val themeColor: Int) : Screen { + data class State(val isBookmarked: Boolean, val themeColor: Color, val toggle: () -> Unit) : + CircuitUiState +} + +class BookmarkIconPresenter +@AssistedInject +constructor( + @Assisted private val screen: BookmarkIconScreen, + private val bookmarkRepository: BookmarkRepository, +) : Presenter { + private val themeColor = Color(screen.themeColor) + + @Composable + override fun present(): BookmarkIconScreen.State { + val isBookmarked by bookmarkRepository.isBookmarked(screen.id).collectAsState(false) + return BookmarkIconScreen.State(isBookmarked, themeColor) { + if (isBookmarked) { + bookmarkRepository.removeBookmark(screen.id) + } else { + bookmarkRepository.addBookmark(screen.id) + } + } + } + + @CircuitInject(BookmarkIconScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: BookmarkIconScreen): BookmarkIconPresenter + } +} + +@CircuitInject(BookmarkIconScreen::class, AppScope::class) +@Composable +fun BookmarkIcon(state: BookmarkIconScreen.State, modifier: Modifier = Modifier) { + val icon = + if (state.isBookmarked) { + Filled.Bookmark + } else { + Outlined.Bookmark + } + TextActionItem( + icon = icon, + tint = state.themeColor, + contentDescription = "Bookmark", + modifier = modifier + ) { + state.toggle() + } +} diff --git a/app/src/main/kotlin/catchup/app/service/FilledBookmark.kt b/app/src/main/kotlin/catchup/app/service/FilledBookmark.kt new file mode 100644 index 000000000..ab5d1afc6 --- /dev/null +++ b/app/src/main/kotlin/catchup/app/service/FilledBookmark.kt @@ -0,0 +1,31 @@ +package catchup.app.service + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +@Suppress("UnusedReceiverParameter") +val Icons.Filled.Bookmark: ImageVector + get() { + if (_filledBookmark != null) { + return _filledBookmark!! + } + _filledBookmark = + materialIcon(name = "Filled.Bookmark") { + materialPath { + moveTo(17.0f, 3.0f) + horizontalLineTo(7.0f) + curveToRelative(-1.1f, 0.0f, -1.99f, 0.9f, -1.99f, 2.0f) + lineTo(5.0f, 21.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + verticalLineTo(5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + } + } + return _filledBookmark!! + } + +@Suppress("ObjectPropertyName") private var _filledBookmark: ImageVector? = null diff --git a/app/src/main/kotlin/catchup/app/service/OutlinedBookmark.kt b/app/src/main/kotlin/catchup/app/service/OutlinedBookmark.kt new file mode 100644 index 000000000..7496a374d --- /dev/null +++ b/app/src/main/kotlin/catchup/app/service/OutlinedBookmark.kt @@ -0,0 +1,38 @@ +package catchup.app.service + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +@Suppress("UnusedReceiverParameter") +val Icons.Outlined.Bookmark: ImageVector + get() { + if (_outlinedBookmark != null) { + return _outlinedBookmark!! + } + _outlinedBookmark = + materialIcon(name = "Outlined.Bookmark") { + materialPath { + moveTo(17.0f, 3.0f) + lineTo(7.0f, 3.0f) + curveToRelative(-1.1f, 0.0f, -1.99f, 0.9f, -1.99f, 2.0f) + lineTo(5.0f, 21.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + lineTo(19.0f, 5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + moveTo(17.0f, 18.0f) + lineToRelative(-5.0f, -2.18f) + lineTo(7.0f, 18.0f) + lineTo(7.0f, 5.0f) + horizontalLineToRelative(10.0f) + verticalLineToRelative(13.0f) + close() + } + } + return _outlinedBookmark!! + } + +@Suppress("ObjectPropertyName") private var _outlinedBookmark: ImageVector? = null diff --git a/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt b/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt index d1bfbcb27..be95ce8a9 100644 --- a/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt @@ -1,7 +1,6 @@ package catchup.app.service import android.content.Intent -import android.widget.Toast import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter @@ -48,7 +47,6 @@ import app.cash.sqldelight.paging3.QueryPagingSource import catchup.app.data.LinkManager import catchup.app.service.ServiceScreen.Event import catchup.app.service.ServiceScreen.Event.ItemActionClicked -import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.FAVORITE import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.SHARE import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.SUMMARIZE import catchup.app.service.ServiceScreen.Event.ItemClicked @@ -114,7 +112,6 @@ data class ServiceScreen(val serviceKey: String) : Screen { data class ItemActionClicked(val item: CatchUpItem, val action: Action) : Event { enum class Action { - FAVORITE, SHARE, SUMMARIZE } @@ -220,9 +217,6 @@ constructor( is ItemActionClicked -> { val url = event.item.clickUrl!! when (event.action) { - FAVORITE -> { - Toast.makeText(context, "Not implemented", Toast.LENGTH_SHORT).show() - } SHARE -> { val shareIntent = Intent().apply { diff --git a/app/src/main/kotlin/catchup/app/service/TextActionItem.kt b/app/src/main/kotlin/catchup/app/service/TextActionItem.kt new file mode 100644 index 000000000..93d80ad68 --- /dev/null +++ b/app/src/main/kotlin/catchup/app/service/TextActionItem.kt @@ -0,0 +1,33 @@ +package catchup.app.service + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun TextActionItem( + icon: ImageVector, + tint: Color, + contentDescription: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit, +) { + IconButton(enabled = enabled, modifier = modifier, onClick = onClick) { + // TODO this always crossfades the initial load for something like bookmarks + Crossfade(icon, label = "Action item crossfade") { + Icon( + imageVector = it, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(24.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt index e77dcdfd4..f5452293b 100644 --- a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt +++ b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt @@ -19,13 +19,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -39,6 +37,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -55,7 +54,6 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import catchup.app.service.ServiceScreen.Event import catchup.app.service.ServiceScreen.Event.ItemActionClicked -import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.FAVORITE import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.SHARE import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.SUMMARIZE import catchup.app.service.ServiceScreen.Event.ItemClicked @@ -68,6 +66,7 @@ import catchup.service.api.Mark import catchup.service.api.canBeSummarized import catchup.util.kotlin.format import catchup.util.primaryLocale +import com.slack.circuit.foundation.CircuitContent import dev.zacsweers.catchup.R import kotlin.time.Duration.Companion.hours import kotlinx.datetime.Clock @@ -128,32 +127,19 @@ fun TextServiceUi( horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) ) { - IconButton(onClick = { eventSink(ItemActionClicked(item, FAVORITE)) }) { - Icon( - imageVector = Icons.Filled.FavoriteBorder, - contentDescription = "Favorite", - tint = themeColor, - modifier = Modifier.size(24.dp) - ) + val bookmarkIconScreen = + remember(item.id) { BookmarkIconScreen(item.id, themeColor.toArgb()) } + CircuitContent(bookmarkIconScreen) + TextActionItem(Icons.Filled.Share, themeColor, "Share") { + eventSink(ItemActionClicked(item, SHARE)) } - IconButton(onClick = { eventSink(ItemActionClicked(item, SHARE)) }) { - Icon( - imageVector = Icons.Filled.Share, - contentDescription = "Share", - tint = themeColor, - modifier = Modifier.size(24.dp) - ) - } - IconButton( + TextActionItem( + Icons.Filled.Info, + themeColor, + "Summarize", enabled = item.canBeSummarized, - onClick = { eventSink(ItemActionClicked(item, SUMMARIZE)) } ) { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = "Summarize", - tint = themeColor, - modifier = Modifier.size(24.dp) - ) + eventSink(ItemActionClicked(item, SUMMARIZE)) } } } diff --git a/bookmarks/build.gradle.kts b/bookmarks/build.gradle.kts index 017a0aaf8..d574898fc 100644 --- a/bookmarks/build.gradle.kts +++ b/bookmarks/build.gradle.kts @@ -19,6 +19,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) alias(libs.plugins.sgp.base) + alias(libs.plugins.anvil) } kotlin { @@ -35,7 +36,7 @@ kotlin { api(libs.kotlin.datetime) api(libs.sqldelight.runtime) api(libs.sqldelight.coroutines) - implementation(projects.bookmarks.db) + api(projects.bookmarks.db) implementation(projects.serviceApi) } } diff --git a/bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt b/bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt new file mode 100644 index 000000000..7387799f7 --- /dev/null +++ b/bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt @@ -0,0 +1,12 @@ +package catchup.bookmarks + +import catchup.bookmarks.db.CatchUpDatabase +import catchup.di.AppScope +import catchup.di.SingleIn +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class BookmarkRepositoryBinding @Inject constructor(private val database: CatchUpDatabase) : + BookmarkRepository by BookmarkRepositoryImpl(database) diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index d1dbab7c4..7337bf6df 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -38,8 +38,10 @@ internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : B init { scope.launch { - database.bookmarksQueries.bookmarkIds().asFlow().collect { query -> - // Preserve + val idsFlow = + database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } + idsFlow.collect { query -> + // Preserve order bookmarks.emit(query.executeAsList().mapTo(LinkedHashSet(), Bookmark::id)) } } From c3b150795c96b60b188e3de549f7a2db5dc28239 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 9 Sep 2023 21:31:10 -0400 Subject: [PATCH 05/32] Simplify DI --- bookmarks/build.gradle.kts | 3 +++ .../src/androidMain/kotlin/catchup/bookmarks/di.kt | 12 ------------ 2 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt diff --git a/bookmarks/build.gradle.kts b/bookmarks/build.gradle.kts index d574898fc..85e1e4b07 100644 --- a/bookmarks/build.gradle.kts +++ b/bookmarks/build.gradle.kts @@ -40,6 +40,9 @@ kotlin { implementation(projects.serviceApi) } } + val jvmMain by getting + val androidMain by getting + androidMain.dependsOn(jvmMain) } } diff --git a/bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt b/bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt deleted file mode 100644 index 7387799f7..000000000 --- a/bookmarks/src/androidMain/kotlin/catchup/bookmarks/di.kt +++ /dev/null @@ -1,12 +0,0 @@ -package catchup.bookmarks - -import catchup.bookmarks.db.CatchUpDatabase -import catchup.di.AppScope -import catchup.di.SingleIn -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject - -@ContributesBinding(AppScope::class) -@SingleIn(AppScope::class) -class BookmarkRepositoryBinding @Inject constructor(private val database: CatchUpDatabase) : - BookmarkRepository by BookmarkRepositoryImpl(database) From 32c7dabc76c9ce0c8f21dd2632ef7c7801360e78 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 9 Sep 2023 21:33:03 -0400 Subject: [PATCH 06/32] Add missing dep --- bookmarks/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bookmarks/build.gradle.kts b/bookmarks/build.gradle.kts index 85e1e4b07..d47fbd235 100644 --- a/bookmarks/build.gradle.kts +++ b/bookmarks/build.gradle.kts @@ -40,7 +40,11 @@ kotlin { implementation(projects.serviceApi) } } - val jvmMain by getting + val jvmMain by getting { + dependencies { + implementation(libs.anvil.annotations) + } + } val androidMain by getting androidMain.dependsOn(jvmMain) } From 72e901b33c57408d7bd4b1366ab34061014ff9f0 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 9 Sep 2023 21:51:48 -0400 Subject: [PATCH 07/32] Move packages --- .../main/kotlin/catchup/app/service/TextServiceUi.kt | 1 + .../service/{ => bookmarks}/BookmarkIconScreen.kt | 12 +++++++----- .../app/service/{ => bookmarks}/FilledBookmark.kt | 2 +- .../app/service/{ => bookmarks}/OutlinedBookmark.kt | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) rename app/src/main/kotlin/catchup/app/service/{ => bookmarks}/BookmarkIconScreen.kt (86%) rename app/src/main/kotlin/catchup/app/service/{ => bookmarks}/FilledBookmark.kt (96%) rename app/src/main/kotlin/catchup/app/service/{ => bookmarks}/OutlinedBookmark.kt (96%) diff --git a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt index f5452293b..0fbac485f 100644 --- a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt +++ b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt @@ -58,6 +58,7 @@ import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.SHARE import catchup.app.service.ServiceScreen.Event.ItemActionClicked.Action.SUMMARIZE import catchup.app.service.ServiceScreen.Event.ItemClicked import catchup.app.service.ServiceScreen.Event.MarkClicked +import catchup.app.service.bookmarks.BookmarkIconScreen import catchup.compose.CatchUpTheme import catchup.compose.ContentAlphas import catchup.compose.ScrollToTopHandler diff --git a/app/src/main/kotlin/catchup/app/service/BookmarkIconScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarkIconScreen.kt similarity index 86% rename from app/src/main/kotlin/catchup/app/service/BookmarkIconScreen.kt rename to app/src/main/kotlin/catchup/app/service/bookmarks/BookmarkIconScreen.kt index 6610b43e8..85e664da0 100644 --- a/app/src/main/kotlin/catchup/app/service/BookmarkIconScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarkIconScreen.kt @@ -1,4 +1,4 @@ -package catchup.app.service +package catchup.app.service.bookmarks import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.Icons.Outlined @@ -7,6 +7,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import catchup.app.service.TextActionItem +import catchup.app.service.bookmarks.BookmarkIconScreen.State import catchup.bookmarks.BookmarkRepository import catchup.di.AppScope import com.slack.circuit.codegen.annotations.CircuitInject @@ -29,13 +31,13 @@ class BookmarkIconPresenter constructor( @Assisted private val screen: BookmarkIconScreen, private val bookmarkRepository: BookmarkRepository, -) : Presenter { +) : Presenter { private val themeColor = Color(screen.themeColor) @Composable - override fun present(): BookmarkIconScreen.State { + override fun present(): State { val isBookmarked by bookmarkRepository.isBookmarked(screen.id).collectAsState(false) - return BookmarkIconScreen.State(isBookmarked, themeColor) { + return State(isBookmarked, themeColor) { if (isBookmarked) { bookmarkRepository.removeBookmark(screen.id) } else { @@ -53,7 +55,7 @@ constructor( @CircuitInject(BookmarkIconScreen::class, AppScope::class) @Composable -fun BookmarkIcon(state: BookmarkIconScreen.State, modifier: Modifier = Modifier) { +fun BookmarkIcon(state: State, modifier: Modifier = Modifier) { val icon = if (state.isBookmarked) { Filled.Bookmark diff --git a/app/src/main/kotlin/catchup/app/service/FilledBookmark.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/FilledBookmark.kt similarity index 96% rename from app/src/main/kotlin/catchup/app/service/FilledBookmark.kt rename to app/src/main/kotlin/catchup/app/service/bookmarks/FilledBookmark.kt index ab5d1afc6..0fcbad7b8 100644 --- a/app/src/main/kotlin/catchup/app/service/FilledBookmark.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/FilledBookmark.kt @@ -1,4 +1,4 @@ -package catchup.app.service +package catchup.app.service.bookmarks import androidx.compose.material.icons.Icons import androidx.compose.material.icons.materialIcon diff --git a/app/src/main/kotlin/catchup/app/service/OutlinedBookmark.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/OutlinedBookmark.kt similarity index 96% rename from app/src/main/kotlin/catchup/app/service/OutlinedBookmark.kt rename to app/src/main/kotlin/catchup/app/service/bookmarks/OutlinedBookmark.kt index 7496a374d..1d3c703eb 100644 --- a/app/src/main/kotlin/catchup/app/service/OutlinedBookmark.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/OutlinedBookmark.kt @@ -1,4 +1,4 @@ -package catchup.app.service +package catchup.app.service.bookmarks import androidx.compose.material.icons.Icons import androidx.compose.material.icons.materialIcon From 46158d1fca5eed83bbde0c3f50647032c84153d1 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 00:24:50 -0400 Subject: [PATCH 08/32] Add itemsByIds --- .../src/commonMain/sqldelight/catchup/service/db/service.sq | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq b/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq index 1d72260d6..2650f3efb 100644 --- a/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq +++ b/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq @@ -112,6 +112,11 @@ ORDER BY indexInResponse ASC -- Used for PagingSource LIMIT ? OFFSET ?; +itemsByIds: +SELECT * +FROM catchUpDbItem +WHERE id IN ?; + deleteItemsByService: DELETE FROM catchUpDbItem WHERE serviceId = ?; From 4fee786f922a653b2227e91c1691aa9a4fdba5ba Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 00:26:23 -0400 Subject: [PATCH 09/32] Implement BookmarksScreen --- .../kotlin/catchup/app/home/HomeScreen.kt | 63 ++++-- .../catchup/app/service/TextServiceUi.kt | 20 +- .../app/service/bookmarks/BookmarksScreen.kt | 198 ++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 254 insertions(+), 28 deletions(-) create mode 100644 app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt diff --git a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt index e574c3230..5d386dbd6 100644 --- a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt +++ b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt @@ -61,11 +61,14 @@ import androidx.window.layout.FoldingFeature import catchup.app.CatchUpPreferences import catchup.app.changes.ChangelogHelper import catchup.app.home.HomeScreen.Event.NestedNavEvent +import catchup.app.home.HomeScreen.Event.OpenBookmarks import catchup.app.home.HomeScreen.Event.OpenSettings import catchup.app.home.HomeScreen.Event.Selected import catchup.app.home.HomeScreen.Event.ShowChangelog import catchup.app.home.HomeScreen.State import catchup.app.service.ServiceScreen +import catchup.app.service.bookmarks.Bookmark +import catchup.app.service.bookmarks.BookmarksScreen import catchup.app.ui.activity.SettingsScreen import catchup.compose.LocalDisplayFeatures import catchup.compose.LocalDynamicTheme @@ -128,6 +131,8 @@ object HomeScreen : Screen, DeepLinkable { sealed interface Event : CircuitUiEvent { data object OpenSettings : Event + data object OpenBookmarks : Event + data object ShowChangelog : Event data class NestedNavEvent(val navEvent: NavEvent) : Event @@ -154,23 +159,23 @@ constructor( @Composable override fun present(): State { val currentOrder by - remember { catchUpPreferences.servicesOrder }.collectAsState(initial = persistentListOf()) + remember { catchUpPreferences.servicesOrder }.collectAsState(initial = persistentListOf()) var selectedIndex by remember(currentOrder) { mutableIntStateOf(0) } val serviceMetas by - produceState(initialValue = persistentListOf(), currentOrder) { - // TODO make enabledPrefKey live? - check(serviceMetaMap.isNotEmpty()) { "No services found!" } - value = - serviceMetaMap.values - .filter(ServiceMeta::enabled) - .filter { serviceMeta -> - catchUpPreferences.datastore.data - .map { it[booleanPreferencesKey(serviceMeta.enabledPreferenceKey)] ?: true } - .first() - } - .sortedBy { currentOrder.indexOf(it.id) } - .toImmutableList() - } + produceState(initialValue = persistentListOf(), currentOrder) { + // TODO make enabledPrefKey live? + check(serviceMetaMap.isNotEmpty()) { "No services found!" } + value = + serviceMetaMap.values + .filter(ServiceMeta::enabled) + .filter { serviceMeta -> + catchUpPreferences.datastore.data + .map { it[booleanPreferencesKey(serviceMeta.enabledPreferenceKey)] ?: true } + .first() + } + .sortedBy { currentOrder.indexOf(it.id) } + .toImmutableList() + } val context = LocalContext.current val changelogAvailable by changelogHelper.changelogAvailable(context).collectAsState(false) @@ -185,14 +190,21 @@ constructor( OpenSettings -> { navigator.goTo(SettingsScreen) } + + OpenBookmarks -> { + navigator.goTo(BookmarksScreen) + } + is NestedNavEvent -> { navigator.onNavEvent(event.navEvent) } + is Selected -> { selectedIndex = event.index // TODO only do this if we make a TwoPane nav-aware // navigator.goTo(ServiceScreen(serviceMetas[event.index].id)) } + ShowChangelog -> { scope.launch { overlayHost.show( @@ -267,10 +279,10 @@ fun Home(state: State, modifier: Modifier = Modifier) { strategy = { density, layoutDirection, layoutCoordinates -> // Split vertically if the height is larger than the width if (layoutCoordinates.size.height >= layoutCoordinates.size.width) { - HorizontalTwoPaneStrategy(splitFraction = 0.4f) - } else { - HorizontalTwoPaneStrategy(splitFraction = 0.5f) - } + HorizontalTwoPaneStrategy(splitFraction = 0.4f) + } else { + HorizontalTwoPaneStrategy(splitFraction = 0.5f) + } .calculateSplitResult(density, layoutDirection, layoutCoordinates) }, displayFeatures = displayFeatures, @@ -372,6 +384,14 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { if (state.changelogAvailable) { ChangelogButton { state.eventSink(ShowChangelog) } } + IconButton( + onClick = { state.eventSink(OpenBookmarks) }, + ) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = "Bookmarks", + ) + } IconButton( onClick = { state.eventSink(OpenSettings) }, ) { @@ -385,7 +405,10 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { }, ) { innerPadding -> Column( - modifier = Modifier.fillMaxSize().padding(innerPadding).consumeWindowInsets(innerPadding) + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding) ) { val scrollToTop = remember { MutableScrollToTop() } val contentColor = diff --git a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt index 0fbac485f..b7831ba23 100644 --- a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt +++ b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -126,7 +127,9 @@ fun TextServiceUi( if (index == expandedItemIndex) { Row( horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) ) { val bookmarkIconScreen = remember(item.id) { BookmarkIconScreen(item.id, themeColor.toArgb()) } @@ -182,13 +185,14 @@ fun TextItem( item.mark?.let { mark -> Column( modifier = - Modifier.padding(start = 16.dp) - .clickable( - enabled = item.markClickUrl != null, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), - onClick = onMarkClick - ), + Modifier + .padding(start = 16.dp) + .clickable( + enabled = item.markClickUrl != null, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = onMarkClick + ), horizontalAlignment = Alignment.CenterHorizontally ) { val icon = diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt new file mode 100644 index 000000000..709924e25 --- /dev/null +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -0,0 +1,198 @@ +package catchup.app.service.bookmarks + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.DismissDirection.EndToStart +import androidx.compose.material3.DismissDirection.StartToEnd +import androidx.compose.material3.DismissValue.Default +import androidx.compose.material3.DismissValue.DismissedToEnd +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SwipeToDismiss +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDismissState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import androidx.paging.map +import app.cash.sqldelight.paging3.QueryPagingSource +import catchup.app.service.ClickableItem +import catchup.app.service.PlaceholderItem +import catchup.app.service.TextItem +import catchup.app.service.bookmarks.BookmarksScreen.Event.Remove +import catchup.app.ui.about.ChangelogScreen.Event.Click +import catchup.base.ui.BackPressNavButton +import catchup.bookmarks.BookmarkRepository +import catchup.deeplink.DeepLinkable +import catchup.di.AppScope +import catchup.service.api.CatchUpItem +import catchup.service.api.toCatchUpItem +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.multibindings.StringKey +import dev.zacsweers.catchup.R.string +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.parcelize.Parcelize + +@ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) +@StringKey("bookmarks") +@Parcelize +object BookmarksScreen : Screen, DeepLinkable { + override fun createScreen(queryParams: ImmutableMap>) = BookmarksScreen + + data class State(val items: Flow>, + val eventSink: (Event) -> Unit) : CircuitUiState + + sealed interface Event : CircuitUiEvent { + data class Remove(val id: Long) : Event + } +} + +@CircuitInject(BookmarksScreen::class, AppScope::class) +@OptIn(ExperimentalPagingApi::class) +class BookmarksPresenter @Inject constructor( + private val bookmarksRepository: BookmarkRepository +) : Presenter { + @Composable + override fun present(): BookmarksScreen.State { + val pager = remember { + // TODO + // retain pager or even the flow? + Pager( + config = PagingConfig(pageSize = 50), + initialKey = 0, + remoteMediator = null + ) { + QueryPagingSource( + countQuery = bookmarksRepository.bookmarksCountQuery(), + transacter = bookmarksRepository.bookmarksTransacter(), + context = Dispatchers.IO, + queryProvider = { limit, offset -> + bookmarksRepository.bookmarksQuery(limit, offset) + }, + ) + } + } + val items: Flow> = + remember(pager) { pager.flow } + return BookmarksScreen.State(items) { event -> + when (event) { + is Remove -> bookmarksRepository.removeBookmark(event.id) + } + } + } +} + +@CircuitInject(BookmarksScreen::class, AppScope::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { + val lazyItems: LazyPagingItems = state.items.collectAsLazyPagingItems() + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { Text(stringResource(string.title_bookmarks)) }, + navigationIcon = { BackPressNavButton() }, + ) + }, + ) { + // TODO empty state + LazyColumn(modifier = Modifier.padding(it)) { + items( + count = lazyItems.itemCount, + // Here we use the new itemKey extension on LazyPagingItems to + // handle placeholders automatically, ensuring you only need to provide + // keys for real items + key = lazyItems.itemKey { it.id }, + ) { index -> + val item = lazyItems[index] + if (item == null) { + PlaceholderItem(Color.Unspecified) + } else { + val dismissState = rememberDismissState( + confirmValueChange = { + if (it != Default) { + state.eventSink(Remove(item.id)) + // TODO this is a jarring animation to do immediately + // TODO offer an undo option + lazyItems.refresh() + } + true + } + ) + SwipeToDismiss(state = dismissState, + background = { + val alignment = when (dismissState.dismissDirection) { + StartToEnd -> Alignment.CenterStart + EndToStart -> Alignment.CenterEnd + null -> Alignment.CenterStart + } + Box( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.error), + contentAlignment = alignment, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier + .padding(32.dp) + .size(32.dp), + tint = MaterialTheme.colorScheme.onError + ) + } + }, + dismissContent = { + // TODO where's the elevation on press/drag? + ClickableItem( + modifier = Modifier.animateItemPlacement(), + onClick = { /* TODO */ }, + ) { + Column(Modifier.animateContentSize()) { + TextItem(item, Color.Unspecified) + } + } + }) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f28e2cb0..3084df5c2 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ Enable smart linking Smart linking will open links in a dedicated app if one exists, otherwise it will fall back to using a Chrome custom tab or browser. Settings + Bookmarks Cleaned %s An app for catching up on things. Version %1s From 296fc0534b7ff2819d5d490bdd6ec47b2b80a233 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 00:26:50 -0400 Subject: [PATCH 10/32] Split DBs to make this at least work --- .../catchup/app/data/CatchUpDatabaseModule.kt | 30 ++++++---------- bookmarks/db/build.gradle.kts | 3 +- .../catchup/bookmarks/db/bookmarks.sq | 12 +++---- .../catchup/bookmarks/BookmarkRepository.kt | 34 ++++++++++++++----- .../jvmMain/kotlin/catchup/bookmarks/di.kt | 9 +++-- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt b/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt index f56007e26..88dac29d1 100644 --- a/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt +++ b/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt @@ -21,7 +21,7 @@ import app.cash.sqldelight.adapter.primitive.FloatColumnAdapter import app.cash.sqldelight.adapter.primitive.IntColumnAdapter import app.cash.sqldelight.driver.android.AndroidSqliteDriver import catchup.bookmarks.db.Bookmark -import catchup.bookmarks.db.CatchUpDatabase as BookmarksDatabase +import catchup.bookmarks.db.BookmarksDatabase import catchup.di.AppScope import catchup.di.SingleIn import catchup.service.db.CatchUpDatabase @@ -39,37 +39,29 @@ object CatchUpDatabaseModule { @SingleIn(AppScope::class) fun provideCatchUpDatabase( @ApplicationContext context: Context, - itemAdapter: CatchUpDbItem.Adapter ): CatchUpDatabase = CatchUpDatabase( AndroidSqliteDriver(CatchUpDatabase.Schema, context, "catchUpItems.db"), - itemAdapter, - ) - - @Provides - @SingleIn(AppScope::class) - fun provideCatchUpDbItemAdapter(): CatchUpDbItem.Adapter = - CatchUpDbItem.Adapter( - InstantColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - FloatColumnAdapter, - IntColumnAdapter, + CatchUpDbItem.Adapter( + InstantColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + FloatColumnAdapter, + IntColumnAdapter, + ), ) @Provides @SingleIn(AppScope::class) fun provideBookmarksDatabase( @ApplicationContext context: Context, - itemAdapter: CatchUpDbItem.Adapter ): BookmarksDatabase = BookmarksDatabase( AndroidSqliteDriver(BookmarksDatabase.Schema, context, "bookmarks.db"), Bookmark.Adapter(InstantColumnAdapter), - itemAdapter, ) } diff --git a/bookmarks/db/build.gradle.kts b/bookmarks/db/build.gradle.kts index a79efbb6d..1f762280a 100644 --- a/bookmarks/db/build.gradle.kts +++ b/bookmarks/db/build.gradle.kts @@ -51,9 +51,8 @@ slack { sqldelight { databases { - create("CatchUpDatabase") { + create("BookmarksDatabase") { packageName.set("catchup.bookmarks.db") - dependency(projects.serviceDb) schemaOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/databases")) migrationOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/migrations")) } diff --git a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq index 358cfbaaf..e1f722587 100644 --- a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq +++ b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq @@ -19,11 +19,7 @@ bookmarkedItemsCount: SELECT count(*) FROM bookmark; bookmarkedItems: -SELECT c.* -FROM catchUpDbItem AS c -JOIN ( - SELECT id - FROM bookmark - ORDER BY timestamp - LIMIT :limit OFFSET :offset -) AS b ON c.id = b.id; +SELECT id +FROM bookmark +ORDER BY bookmark.timestamp +LIMIT :limit OFFSET :offset; diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index 7337bf6df..f2cd4e9d2 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -1,13 +1,15 @@ package catchup.bookmarks import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.SqlCursor import catchup.bookmarks.db.Bookmark -import catchup.bookmarks.db.CatchUpDatabase +import catchup.bookmarks.db.BookmarksDatabase import catchup.service.api.CatchUpItem import catchup.service.api.toCatchUpItem +import catchup.service.db.CatchUpDatabase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -26,11 +28,15 @@ interface BookmarkRepository { // Exposed to create a PagingSource fun bookmarksCountQuery(): Query + fun bookmarksTransacter(): Transacter fun bookmarksQuery(limit: Long, offset: Long): Query } -internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : BookmarkRepository { +internal class BookmarkRepositoryImpl( + private val bookmarksDb: BookmarksDatabase, + private val catchupDb: CatchUpDatabase +) : BookmarkRepository { private val scope = CoroutineScope(Dispatchers.IO) // Maintain an in-memory cache of all the bookmarks @@ -39,7 +45,7 @@ internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : B init { scope.launch { val idsFlow = - database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } + bookmarksDb.transactionWithResult { bookmarksDb.bookmarksQueries.bookmarkIds().asFlow() } idsFlow.collect { query -> // Preserve order bookmarks.emit(query.executeAsList().mapTo(LinkedHashSet(), Bookmark::id)) @@ -48,19 +54,23 @@ internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : B } override fun addBookmark(id: Long, timestamp: Instant) { - scope.launch { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } + scope.launch { bookmarksDb.transaction { bookmarksDb.bookmarksQueries.addBookmark(id, timestamp) } } } override fun removeBookmark(id: Long) { - scope.launch { database.transaction { database.bookmarksQueries.removeBookmark(id) } } + scope.launch { bookmarksDb.transaction { bookmarksDb.bookmarksQueries.removeBookmark(id) } } } override fun isBookmarked(id: Long) = bookmarks.map { id in it } - override fun bookmarksCountQuery() = database.bookmarksQueries.bookmarkedItemsCount() + override fun bookmarksCountQuery() = bookmarksDb.bookmarksQueries.bookmarkedItemsCount() + + override fun bookmarksTransacter(): Transacter = bookmarksDb.bookmarksQueries override fun bookmarksQuery(limit: Long, offset: Long): Query { - return database.bookmarksQueries.bookmarkedItems(limit, offset).map { it.toCatchUpItem() } + val bookmarkIds = bookmarksDb.bookmarksQueries.bookmarkedItems(limit, offset) + return catchupDb.serviceQueries.itemsByIds(bookmarkIds.executeAsList()) + .map { it.toCatchUpItem() } } } @@ -77,7 +87,15 @@ private class MappedQuery(private val original: Query, private val newMappe QueryResult.AsyncValue { delegate.value?.let(newMapper) } as QueryResult } is QueryResult.Value -> { - val mappedValue = delegate.value?.let(newMapper) + val mappedValue = delegate.value?.let { + if (it is List<*>) { + it.map { + it?.let(newMapper) + } + } else { + newMapper(it) + } + } @Suppress("UNCHECKED_CAST") QueryResult.Value(mappedValue) as QueryResult } diff --git a/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt b/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt index 7387799f7..9e404655c 100644 --- a/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt +++ b/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt @@ -1,12 +1,15 @@ package catchup.bookmarks -import catchup.bookmarks.db.CatchUpDatabase +import catchup.bookmarks.db.BookmarksDatabase import catchup.di.AppScope import catchup.di.SingleIn +import catchup.service.db.CatchUpDatabase import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) -class BookmarkRepositoryBinding @Inject constructor(private val database: CatchUpDatabase) : - BookmarkRepository by BookmarkRepositoryImpl(database) +class BookmarkRepositoryBinding @Inject constructor( + bookmarksDb: BookmarksDatabase, + catchupDb: CatchUpDatabase) : + BookmarkRepository by BookmarkRepositoryImpl(bookmarksDb, catchupDb) From 88fa0d1576bed07c2c89a8ebb3be5b93f4e278c8 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 00:35:59 -0400 Subject: [PATCH 11/32] Share a single db again --- .../catchup/app/data/CatchUpDatabaseModule.kt | 57 ++++++------- .../kotlin/catchup/app/home/HomeScreen.kt | 47 +++++------ .../catchup/app/service/TextServiceUi.kt | 20 ++--- .../app/service/bookmarks/BookmarksScreen.kt | 81 ++++++++----------- bookmarks/db/build.gradle.kts | 3 +- .../catchup/bookmarks/db/bookmarks.sq | 5 +- .../catchup/bookmarks/BookmarkRepository.kt | 36 ++++----- .../jvmMain/kotlin/catchup/bookmarks/di.kt | 9 +-- .../sqldelight/catchup/service/db/service.sq | 5 -- 9 files changed, 112 insertions(+), 151 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt b/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt index 88dac29d1..68d92b5ed 100644 --- a/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt +++ b/app/src/main/kotlin/catchup/app/data/CatchUpDatabaseModule.kt @@ -21,48 +21,49 @@ import app.cash.sqldelight.adapter.primitive.FloatColumnAdapter import app.cash.sqldelight.adapter.primitive.IntColumnAdapter import app.cash.sqldelight.driver.android.AndroidSqliteDriver import catchup.bookmarks.db.Bookmark -import catchup.bookmarks.db.BookmarksDatabase +import catchup.bookmarks.db.CatchUpDatabase as BookmarksDatabase import catchup.di.AppScope import catchup.di.SingleIn import catchup.service.db.CatchUpDatabase import catchup.service.db.CatchUpDbItem import catchup.util.injection.qualifiers.ApplicationContext import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds import dagger.Module import dagger.Provides import kotlinx.datetime.Instant +/** + * This setup is a little weird but apparently how SqlDelight works. + * + * [BookmarksDatabase] is the "real" db instance, but they all implement the same base interface. + */ @ContributesTo(AppScope::class) @Module -object CatchUpDatabaseModule { - @Provides +abstract class CatchUpDatabaseModule { + @Binds @SingleIn(AppScope::class) - fun provideCatchUpDatabase( - @ApplicationContext context: Context, - ): CatchUpDatabase = - CatchUpDatabase( - AndroidSqliteDriver(CatchUpDatabase.Schema, context, "catchUpItems.db"), - CatchUpDbItem.Adapter( - InstantColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - IntColumnAdapter, - FloatColumnAdapter, - IntColumnAdapter, - ), - ) + abstract fun provideCatchUpDatabase(real: BookmarksDatabase): CatchUpDatabase - @Provides - @SingleIn(AppScope::class) - fun provideBookmarksDatabase( - @ApplicationContext context: Context, - ): BookmarksDatabase = - BookmarksDatabase( - AndroidSqliteDriver(BookmarksDatabase.Schema, context, "bookmarks.db"), - Bookmark.Adapter(InstantColumnAdapter), - ) + companion object { + @Provides + @SingleIn(AppScope::class) + fun provideBookmarksDatabase(@ApplicationContext context: Context): BookmarksDatabase = + BookmarksDatabase( + AndroidSqliteDriver(BookmarksDatabase.Schema, context, "catchup.db"), + Bookmark.Adapter(InstantColumnAdapter), + CatchUpDbItem.Adapter( + InstantColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + IntColumnAdapter, + FloatColumnAdapter, + IntColumnAdapter, + ), + ) + } } object InstantColumnAdapter : ColumnAdapter { diff --git a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt index 5d386dbd6..185069077 100644 --- a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt +++ b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt @@ -159,23 +159,23 @@ constructor( @Composable override fun present(): State { val currentOrder by - remember { catchUpPreferences.servicesOrder }.collectAsState(initial = persistentListOf()) + remember { catchUpPreferences.servicesOrder }.collectAsState(initial = persistentListOf()) var selectedIndex by remember(currentOrder) { mutableIntStateOf(0) } val serviceMetas by - produceState(initialValue = persistentListOf(), currentOrder) { - // TODO make enabledPrefKey live? - check(serviceMetaMap.isNotEmpty()) { "No services found!" } - value = - serviceMetaMap.values - .filter(ServiceMeta::enabled) - .filter { serviceMeta -> - catchUpPreferences.datastore.data - .map { it[booleanPreferencesKey(serviceMeta.enabledPreferenceKey)] ?: true } - .first() - } - .sortedBy { currentOrder.indexOf(it.id) } - .toImmutableList() - } + produceState(initialValue = persistentListOf(), currentOrder) { + // TODO make enabledPrefKey live? + check(serviceMetaMap.isNotEmpty()) { "No services found!" } + value = + serviceMetaMap.values + .filter(ServiceMeta::enabled) + .filter { serviceMeta -> + catchUpPreferences.datastore.data + .map { it[booleanPreferencesKey(serviceMeta.enabledPreferenceKey)] ?: true } + .first() + } + .sortedBy { currentOrder.indexOf(it.id) } + .toImmutableList() + } val context = LocalContext.current val changelogAvailable by changelogHelper.changelogAvailable(context).collectAsState(false) @@ -190,21 +190,17 @@ constructor( OpenSettings -> { navigator.goTo(SettingsScreen) } - OpenBookmarks -> { navigator.goTo(BookmarksScreen) } - is NestedNavEvent -> { navigator.onNavEvent(event.navEvent) } - is Selected -> { selectedIndex = event.index // TODO only do this if we make a TwoPane nav-aware // navigator.goTo(ServiceScreen(serviceMetas[event.index].id)) } - ShowChangelog -> { scope.launch { overlayHost.show( @@ -279,10 +275,10 @@ fun Home(state: State, modifier: Modifier = Modifier) { strategy = { density, layoutDirection, layoutCoordinates -> // Split vertically if the height is larger than the width if (layoutCoordinates.size.height >= layoutCoordinates.size.width) { - HorizontalTwoPaneStrategy(splitFraction = 0.4f) - } else { - HorizontalTwoPaneStrategy(splitFraction = 0.5f) - } + HorizontalTwoPaneStrategy(splitFraction = 0.4f) + } else { + HorizontalTwoPaneStrategy(splitFraction = 0.5f) + } .calculateSplitResult(density, layoutDirection, layoutCoordinates) }, displayFeatures = displayFeatures, @@ -405,10 +401,7 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { }, ) { innerPadding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .consumeWindowInsets(innerPadding) + modifier = Modifier.fillMaxSize().padding(innerPadding).consumeWindowInsets(innerPadding) ) { val scrollToTop = remember { MutableScrollToTop() } val contentColor = diff --git a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt index b7831ba23..0fbac485f 100644 --- a/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt +++ b/app/src/main/kotlin/catchup/app/service/TextServiceUi.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -127,9 +126,7 @@ fun TextServiceUi( if (index == expandedItemIndex) { Row( horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) ) { val bookmarkIconScreen = remember(item.id) { BookmarkIconScreen(item.id, themeColor.toArgb()) } @@ -185,14 +182,13 @@ fun TextItem( item.mark?.let { mark -> Column( modifier = - Modifier - .padding(start = 16.dp) - .clickable( - enabled = item.markClickUrl != null, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), - onClick = onMarkClick - ), + Modifier.padding(start = 16.dp) + .clickable( + enabled = item.markClickUrl != null, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = onMarkClick + ), horizontalAlignment = Alignment.CenterHorizontally ) { val icon = diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index 709924e25..b907f7de3 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.DismissDirection.EndToStart import androidx.compose.material3.DismissDirection.StartToEnd import androidx.compose.material3.DismissValue.Default -import androidx.compose.material3.DismissValue.DismissedToEnd import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -29,7 +28,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Color.Companion import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.ExperimentalPagingApi @@ -39,19 +37,16 @@ import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey -import androidx.paging.map import app.cash.sqldelight.paging3.QueryPagingSource import catchup.app.service.ClickableItem import catchup.app.service.PlaceholderItem import catchup.app.service.TextItem import catchup.app.service.bookmarks.BookmarksScreen.Event.Remove -import catchup.app.ui.about.ChangelogScreen.Event.Click import catchup.base.ui.BackPressNavButton import catchup.bookmarks.BookmarkRepository import catchup.deeplink.DeepLinkable import catchup.di.AppScope import catchup.service.api.CatchUpItem -import catchup.service.api.toCatchUpItem import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState @@ -64,7 +59,6 @@ import javax.inject.Inject import kotlinx.collections.immutable.ImmutableMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.parcelize.Parcelize @ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) @@ -73,8 +67,8 @@ import kotlinx.parcelize.Parcelize object BookmarksScreen : Screen, DeepLinkable { override fun createScreen(queryParams: ImmutableMap>) = BookmarksScreen - data class State(val items: Flow>, - val eventSink: (Event) -> Unit) : CircuitUiState + data class State(val items: Flow>, val eventSink: (Event) -> Unit) : + CircuitUiState sealed interface Event : CircuitUiEvent { data class Remove(val id: Long) : Event @@ -83,31 +77,23 @@ object BookmarksScreen : Screen, DeepLinkable { @CircuitInject(BookmarksScreen::class, AppScope::class) @OptIn(ExperimentalPagingApi::class) -class BookmarksPresenter @Inject constructor( - private val bookmarksRepository: BookmarkRepository -) : Presenter { +class BookmarksPresenter @Inject constructor(private val bookmarksRepository: BookmarkRepository) : + Presenter { @Composable override fun present(): BookmarksScreen.State { val pager = remember { // TODO // retain pager or even the flow? - Pager( - config = PagingConfig(pageSize = 50), - initialKey = 0, - remoteMediator = null - ) { + Pager(config = PagingConfig(pageSize = 50), initialKey = 0, remoteMediator = null) { QueryPagingSource( countQuery = bookmarksRepository.bookmarksCountQuery(), transacter = bookmarksRepository.bookmarksTransacter(), context = Dispatchers.IO, - queryProvider = { limit, offset -> - bookmarksRepository.bookmarksQuery(limit, offset) - }, + queryProvider = { limit, offset -> bookmarksRepository.bookmarksQuery(limit, offset) }, ) } } - val items: Flow> = - remember(pager) { pager.flow } + val items: Flow> = remember(pager) { pager.flow } return BookmarksScreen.State(items) { event -> when (event) { is Remove -> bookmarksRepository.removeBookmark(event.id) @@ -145,37 +131,35 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { if (item == null) { PlaceholderItem(Color.Unspecified) } else { - val dismissState = rememberDismissState( - confirmValueChange = { - if (it != Default) { - state.eventSink(Remove(item.id)) - // TODO this is a jarring animation to do immediately - // TODO offer an undo option - lazyItems.refresh() + val dismissState = + rememberDismissState( + confirmValueChange = { + if (it != Default) { + state.eventSink(Remove(item.id)) + // TODO this is a jarring animation to do immediately + // TODO offer an undo option + lazyItems.refresh() + } + true } - true - } - ) - SwipeToDismiss(state = dismissState, + ) + SwipeToDismiss( + state = dismissState, background = { - val alignment = when (dismissState.dismissDirection) { - StartToEnd -> Alignment.CenterStart - EndToStart -> Alignment.CenterEnd - null -> Alignment.CenterStart - } + val alignment = + when (dismissState.dismissDirection) { + StartToEnd -> Alignment.CenterStart + EndToStart -> Alignment.CenterEnd + null -> Alignment.CenterStart + } Box( - modifier = Modifier - .fillMaxSize() - .background( - MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.error), contentAlignment = alignment, ) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete", - modifier = Modifier - .padding(32.dp) - .size(32.dp), + modifier = Modifier.padding(32.dp).size(32.dp), tint = MaterialTheme.colorScheme.onError ) } @@ -184,13 +168,12 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { // TODO where's the elevation on press/drag? ClickableItem( modifier = Modifier.animateItemPlacement(), - onClick = { /* TODO */ }, + onClick = { /* TODO */}, ) { - Column(Modifier.animateContentSize()) { - TextItem(item, Color.Unspecified) - } + Column(Modifier.animateContentSize()) { TextItem(item, Color.Unspecified) } } - }) + } + ) } } } diff --git a/bookmarks/db/build.gradle.kts b/bookmarks/db/build.gradle.kts index 1f762280a..a79efbb6d 100644 --- a/bookmarks/db/build.gradle.kts +++ b/bookmarks/db/build.gradle.kts @@ -51,8 +51,9 @@ slack { sqldelight { databases { - create("BookmarksDatabase") { + create("CatchUpDatabase") { packageName.set("catchup.bookmarks.db") + dependency(projects.serviceDb) schemaOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/databases")) migrationOutputDirectory.set(layout.projectDirectory.dir("src/commonMain/sqldelight/migrations")) } diff --git a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq index e1f722587..25be5da9c 100644 --- a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq +++ b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq @@ -19,7 +19,8 @@ bookmarkedItemsCount: SELECT count(*) FROM bookmark; bookmarkedItems: -SELECT id -FROM bookmark +SELECT c.* +FROM catchUpDbItem AS c +INNER JOIN bookmark ON c.id = bookmark.id ORDER BY bookmark.timestamp LIMIT :limit OFFSET :offset; diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index f2cd4e9d2..cfbfb5297 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -6,10 +6,9 @@ import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.SqlCursor import catchup.bookmarks.db.Bookmark -import catchup.bookmarks.db.BookmarksDatabase +import catchup.bookmarks.db.CatchUpDatabase import catchup.service.api.CatchUpItem import catchup.service.api.toCatchUpItem -import catchup.service.db.CatchUpDatabase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -28,15 +27,13 @@ interface BookmarkRepository { // Exposed to create a PagingSource fun bookmarksCountQuery(): Query + fun bookmarksTransacter(): Transacter fun bookmarksQuery(limit: Long, offset: Long): Query } -internal class BookmarkRepositoryImpl( - private val bookmarksDb: BookmarksDatabase, - private val catchupDb: CatchUpDatabase -) : BookmarkRepository { +internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : BookmarkRepository { private val scope = CoroutineScope(Dispatchers.IO) // Maintain an in-memory cache of all the bookmarks @@ -45,7 +42,7 @@ internal class BookmarkRepositoryImpl( init { scope.launch { val idsFlow = - bookmarksDb.transactionWithResult { bookmarksDb.bookmarksQueries.bookmarkIds().asFlow() } + database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } idsFlow.collect { query -> // Preserve order bookmarks.emit(query.executeAsList().mapTo(LinkedHashSet(), Bookmark::id)) @@ -54,23 +51,21 @@ internal class BookmarkRepositoryImpl( } override fun addBookmark(id: Long, timestamp: Instant) { - scope.launch { bookmarksDb.transaction { bookmarksDb.bookmarksQueries.addBookmark(id, timestamp) } } + scope.launch { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } } override fun removeBookmark(id: Long) { - scope.launch { bookmarksDb.transaction { bookmarksDb.bookmarksQueries.removeBookmark(id) } } + scope.launch { database.transaction { database.bookmarksQueries.removeBookmark(id) } } } override fun isBookmarked(id: Long) = bookmarks.map { id in it } - override fun bookmarksCountQuery() = bookmarksDb.bookmarksQueries.bookmarkedItemsCount() + override fun bookmarksCountQuery() = database.bookmarksQueries.bookmarkedItemsCount() - override fun bookmarksTransacter(): Transacter = bookmarksDb.bookmarksQueries + override fun bookmarksTransacter(): Transacter = database.bookmarksQueries override fun bookmarksQuery(limit: Long, offset: Long): Query { - val bookmarkIds = bookmarksDb.bookmarksQueries.bookmarkedItems(limit, offset) - return catchupDb.serviceQueries.itemsByIds(bookmarkIds.executeAsList()) - .map { it.toCatchUpItem() } + return database.bookmarksQueries.bookmarkedItems(limit, offset).map { it.toCatchUpItem() } } } @@ -87,15 +82,14 @@ private class MappedQuery(private val original: Query, private val newMappe QueryResult.AsyncValue { delegate.value?.let(newMapper) } as QueryResult } is QueryResult.Value -> { - val mappedValue = delegate.value?.let { - if (it is List<*>) { - it.map { - it?.let(newMapper) + val mappedValue = + delegate.value?.let { + if (it is List<*>) { + it.map { it?.let(newMapper) } + } else { + newMapper(it) } - } else { - newMapper(it) } - } @Suppress("UNCHECKED_CAST") QueryResult.Value(mappedValue) as QueryResult } diff --git a/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt b/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt index 9e404655c..7387799f7 100644 --- a/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt +++ b/bookmarks/src/jvmMain/kotlin/catchup/bookmarks/di.kt @@ -1,15 +1,12 @@ package catchup.bookmarks -import catchup.bookmarks.db.BookmarksDatabase +import catchup.bookmarks.db.CatchUpDatabase import catchup.di.AppScope import catchup.di.SingleIn -import catchup.service.db.CatchUpDatabase import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) -class BookmarkRepositoryBinding @Inject constructor( - bookmarksDb: BookmarksDatabase, - catchupDb: CatchUpDatabase) : - BookmarkRepository by BookmarkRepositoryImpl(bookmarksDb, catchupDb) +class BookmarkRepositoryBinding @Inject constructor(private val database: CatchUpDatabase) : + BookmarkRepository by BookmarkRepositoryImpl(database) diff --git a/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq b/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq index 2650f3efb..1d72260d6 100644 --- a/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq +++ b/service-db/src/commonMain/sqldelight/catchup/service/db/service.sq @@ -112,11 +112,6 @@ ORDER BY indexInResponse ASC -- Used for PagingSource LIMIT ? OFFSET ?; -itemsByIds: -SELECT * -FROM catchUpDbItem -WHERE id IN ?; - deleteItemsByService: DELETE FROM catchUpDbItem WHERE serviceId = ?; From 47ec62f2b7d9dc8b206720aeeafeb44a464ff9b1 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 00:56:15 -0400 Subject: [PATCH 12/32] Add click handling --- .../app/service/bookmarks/BookmarksScreen.kt | 93 ++++++++++++------- 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index b907f7de3..651255919 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -1,10 +1,8 @@ package catchup.app.service.bookmarks -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -28,22 +26,27 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig -import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import app.cash.sqldelight.paging3.QueryPagingSource +import catchup.app.data.LinkManager import catchup.app.service.ClickableItem import catchup.app.service.PlaceholderItem import catchup.app.service.TextItem +import catchup.app.service.UrlMeta +import catchup.app.service.bookmarks.BookmarksScreen.Event.Click import catchup.app.service.bookmarks.BookmarksScreen.Event.Remove import catchup.base.ui.BackPressNavButton import catchup.bookmarks.BookmarkRepository +import catchup.compose.rememberStableCoroutineScope import catchup.deeplink.DeepLinkable import catchup.di.AppScope import catchup.service.api.CatchUpItem @@ -58,7 +61,7 @@ import dev.zacsweers.catchup.R.string import javax.inject.Inject import kotlinx.collections.immutable.ImmutableMap import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) @@ -67,36 +70,53 @@ import kotlinx.parcelize.Parcelize object BookmarksScreen : Screen, DeepLinkable { override fun createScreen(queryParams: ImmutableMap>) = BookmarksScreen - data class State(val items: Flow>, val eventSink: (Event) -> Unit) : + data class State(val items: LazyPagingItems, val eventSink: (Event) -> Unit) : CircuitUiState sealed interface Event : CircuitUiEvent { + data class Click(val url: String) : Event + data class Remove(val id: Long) : Event } } @CircuitInject(BookmarksScreen::class, AppScope::class) @OptIn(ExperimentalPagingApi::class) -class BookmarksPresenter @Inject constructor(private val bookmarksRepository: BookmarkRepository) : - Presenter { +class BookmarksPresenter +@Inject +constructor( + private val bookmarksRepository: BookmarkRepository, + private val linkManager: LinkManager, +) : Presenter { @Composable override fun present(): BookmarksScreen.State { - val pager = remember { - // TODO - // retain pager or even the flow? + val itemsFlow = remember { Pager(config = PagingConfig(pageSize = 50), initialKey = 0, remoteMediator = null) { - QueryPagingSource( - countQuery = bookmarksRepository.bookmarksCountQuery(), - transacter = bookmarksRepository.bookmarksTransacter(), - context = Dispatchers.IO, - queryProvider = { limit, offset -> bookmarksRepository.bookmarksQuery(limit, offset) }, - ) - } + QueryPagingSource( + countQuery = bookmarksRepository.bookmarksCountQuery(), + transacter = bookmarksRepository.bookmarksTransacter(), + context = Dispatchers.IO, + queryProvider = { limit, offset -> bookmarksRepository.bookmarksQuery(limit, offset) }, + ) + } + .flow } - val items: Flow> = remember(pager) { pager.flow } - return BookmarksScreen.State(items) { event -> + val lazyItems = itemsFlow.collectAsLazyPagingItems() + val scope = rememberStableCoroutineScope() + val context = LocalContext.current + return BookmarksScreen.State(lazyItems) { event -> when (event) { - is Remove -> bookmarksRepository.removeBookmark(event.id) + is Remove -> { + bookmarksRepository.removeBookmark(event.id) + // TODO this is a jarring animation to do immediately + lazyItems.refresh() + } + is Click -> { + scope.launch { + val meta = UrlMeta(event.url, Color.Unspecified.toArgb(), context) + linkManager.openUrl(meta) + } + } } } } @@ -106,7 +126,6 @@ class BookmarksPresenter @Inject constructor(private val bookmarksRepository: Bo @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { - val lazyItems: LazyPagingItems = state.items.collectAsLazyPagingItems() Scaffold( modifier = modifier, contentWindowInsets = WindowInsets(0, 0, 0, 0), @@ -118,16 +137,16 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { ) }, ) { - // TODO empty state - LazyColumn(modifier = Modifier.padding(it)) { + // TODO empty state, but if I do an if/else check on itemCount the swipe dismiss throws an ISE + LazyColumn(Modifier.padding(it)) { items( - count = lazyItems.itemCount, + count = state.items.itemCount, // Here we use the new itemKey extension on LazyPagingItems to // handle placeholders automatically, ensuring you only need to provide // keys for real items - key = lazyItems.itemKey { it.id }, + key = state.items.itemKey { it.id }, ) { index -> - val item = lazyItems[index] + val item = state.items[index] if (item == null) { PlaceholderItem(Color.Unspecified) } else { @@ -136,14 +155,13 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { confirmValueChange = { if (it != Default) { state.eventSink(Remove(item.id)) - // TODO this is a jarring animation to do immediately // TODO offer an undo option - lazyItems.refresh() } true } ) SwipeToDismiss( + modifier = Modifier.animateItemPlacement(), state = dismissState, background = { val alignment = @@ -166,11 +184,20 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { }, dismissContent = { // TODO where's the elevation on press/drag? - ClickableItem( - modifier = Modifier.animateItemPlacement(), - onClick = { /* TODO */}, - ) { - Column(Modifier.animateContentSize()) { TextItem(item, Color.Unspecified) } + val clickUrl = item.clickUrl + if (clickUrl != null) { + ClickableItem( + modifier = Modifier.animateItemPlacement(), + onClick = { state.eventSink(Click(clickUrl)) }, + ) { + TextItem(item, Color.Unspecified) + } + } else { + TextItem( + item, + Color.Unspecified, + modifier = Modifier.animateItemPlacement(), + ) } } ) From 053d061659dbe838138529fbca6f9cd3738483a1 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 01:02:31 -0400 Subject: [PATCH 13/32] Add service colors --- .../app/service/bookmarks/BookmarksScreen.kt | 28 +++++++++++++------ .../catchup/bookmarks/db/bookmarks.sq | 1 - 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index 651255919..5fb46b159 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -1,5 +1,6 @@ package catchup.app.service.bookmarks +import androidx.annotation.ColorInt import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -28,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.ExperimentalPagingApi @@ -50,6 +52,7 @@ import catchup.compose.rememberStableCoroutineScope import catchup.deeplink.DeepLinkable import catchup.di.AppScope import catchup.service.api.CatchUpItem +import catchup.service.api.ServiceMeta import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState @@ -60,6 +63,7 @@ import dagger.multibindings.StringKey import dev.zacsweers.catchup.R.string import javax.inject.Inject import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -70,11 +74,14 @@ import kotlinx.parcelize.Parcelize object BookmarksScreen : Screen, DeepLinkable { override fun createScreen(queryParams: ImmutableMap>) = BookmarksScreen - data class State(val items: LazyPagingItems, val eventSink: (Event) -> Unit) : - CircuitUiState + data class State( + val items: LazyPagingItems, + val serviceMetaMap: ImmutableMap, + val eventSink: (Event) -> Unit, + ) : CircuitUiState sealed interface Event : CircuitUiEvent { - data class Click(val url: String) : Event + data class Click(val url: String, @ColorInt val themeColor: Int) : Event data class Remove(val id: Long) : Event } @@ -87,6 +94,7 @@ class BookmarksPresenter constructor( private val bookmarksRepository: BookmarkRepository, private val linkManager: LinkManager, + private val serviceMetaMap: Map, ) : Presenter { @Composable override fun present(): BookmarksScreen.State { @@ -102,9 +110,10 @@ constructor( .flow } val lazyItems = itemsFlow.collectAsLazyPagingItems() + val metaMap = remember { serviceMetaMap.toImmutableMap() } val scope = rememberStableCoroutineScope() val context = LocalContext.current - return BookmarksScreen.State(lazyItems) { event -> + return BookmarksScreen.State(lazyItems, metaMap) { event -> when (event) { is Remove -> { bookmarksRepository.removeBookmark(event.id) @@ -113,7 +122,7 @@ constructor( } is Click -> { scope.launch { - val meta = UrlMeta(event.url, Color.Unspecified.toArgb(), context) + val meta = UrlMeta(event.url, event.themeColor, context) linkManager.openUrl(meta) } } @@ -160,6 +169,9 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { true } ) + val themeColorRes = + remember(item.serviceId) { state.serviceMetaMap[item.serviceId]?.themeColor } + val themeColor = themeColorRes?.let { colorResource(it) } ?: Color.Unspecified SwipeToDismiss( modifier = Modifier.animateItemPlacement(), state = dismissState, @@ -188,14 +200,14 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { if (clickUrl != null) { ClickableItem( modifier = Modifier.animateItemPlacement(), - onClick = { state.eventSink(Click(clickUrl)) }, + onClick = { state.eventSink(Click(clickUrl, themeColor.toArgb())) }, ) { - TextItem(item, Color.Unspecified) + TextItem(item, themeColor) } } else { TextItem( item, - Color.Unspecified, + themeColor, modifier = Modifier.animateItemPlacement(), ) } diff --git a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq index 25be5da9c..804126391 100644 --- a/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq +++ b/bookmarks/db/src/commonMain/sqldelight/catchup/bookmarks/db/bookmarks.sq @@ -1,6 +1,5 @@ import kotlinx.datetime.Instant; --- TODO capture service tint color? CREATE TABLE IF NOT EXISTS bookmark ( id INTEGER NOT NULL PRIMARY KEY, timestamp INTEGER AS Instant NOT NULL From b498aafca7c71d245654d8a1b0d8b7d02e1895e5 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 01:15:05 -0400 Subject: [PATCH 14/32] Show service origin in start -> end drag --- .../app/service/bookmarks/BookmarksScreen.kt | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index 5fb46b159..c3ac842c0 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.DismissDirection.EndToStart import androidx.compose.material3.DismissDirection.StartToEnd import androidx.compose.material3.DismissValue.Default +import androidx.compose.material3.DismissValue.DismissedToStart import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -28,9 +29,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager @@ -68,6 +71,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +/* + * TODO + * - Empty state + */ + @ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) @StringKey("bookmarks") @Parcelize @@ -162,20 +170,28 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { val dismissState = rememberDismissState( confirmValueChange = { - if (it != Default) { + if (it == DismissedToStart) { state.eventSink(Remove(item.id)) // TODO offer an undo option } - true + it == DismissedToStart } ) - val themeColorRes = - remember(item.serviceId) { state.serviceMetaMap[item.serviceId]?.themeColor } + val serviceMeta = remember(item.serviceId) { state.serviceMetaMap[item.serviceId] } + val themeColorRes = remember(serviceMeta) { serviceMeta?.themeColor } val themeColor = themeColorRes?.let { colorResource(it) } ?: Color.Unspecified + // When swiping from start to end, we don't dismiss and instead use this to indicate + // metadata about the bookmark, like the service it's from. SwipeToDismiss( modifier = Modifier.animateItemPlacement(), state = dismissState, background = { + val color = + when (dismissState.dismissDirection) { + StartToEnd -> themeColor + EndToStart -> MaterialTheme.colorScheme.error + null -> Color.Unspecified + } val alignment = when (dismissState.dismissDirection) { StartToEnd -> Alignment.CenterStart @@ -183,15 +199,31 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { null -> Alignment.CenterStart } Box( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxSize().background(color), contentAlignment = alignment, ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete", - modifier = Modifier.padding(32.dp).size(32.dp), - tint = MaterialTheme.colorScheme.onError - ) + when (dismissState.dismissDirection) { + StartToEnd -> { + serviceMeta?.let { + Icon( + imageVector = ImageVector.vectorResource(it.icon), + contentDescription = stringResource(it.name), + modifier = Modifier.padding(32.dp).size(32.dp), + tint = Color.White, + ) + } + } + EndToStart -> + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.padding(32.dp).size(32.dp), + tint = MaterialTheme.colorScheme.onError + ) + null -> { + // Do nothing + } + } } }, dismissContent = { From f2dfa4138df4ffd4d2ef34dbe33766e9210b988d Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 13:15:37 -0400 Subject: [PATCH 15/32] Dependency sort --- bookmarks/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bookmarks/build.gradle.kts b/bookmarks/build.gradle.kts index d47fbd235..19d8198ea 100644 --- a/bookmarks/build.gradle.kts +++ b/bookmarks/build.gradle.kts @@ -34,9 +34,10 @@ kotlin { commonMain { dependencies { api(libs.kotlin.datetime) - api(libs.sqldelight.runtime) api(libs.sqldelight.coroutines) + api(libs.sqldelight.runtime) api(projects.bookmarks.db) + implementation(projects.serviceApi) } } From 181a26daaa4afe5e6eae8845f65f22210e6be37e Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 13:44:35 -0400 Subject: [PATCH 16/32] Clean up exit animation --- .../app/service/bookmarks/BookmarksScreen.kt | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index c3ac842c0..5f71d97f4 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -125,8 +125,6 @@ constructor( when (event) { is Remove -> { bookmarksRepository.removeBookmark(event.id) - // TODO this is a jarring animation to do immediately - lazyItems.refresh() } is Click -> { scope.launch { @@ -153,9 +151,9 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { navigationIcon = { BackPressNavButton() }, ) }, - ) { + ) { innerPadding -> // TODO empty state, but if I do an if/else check on itemCount the swipe dismiss throws an ISE - LazyColumn(Modifier.padding(it)) { + LazyColumn(Modifier.padding(innerPadding)) { items( count = state.items.itemCount, // Here we use the new itemKey extension on LazyPagingItems to @@ -167,16 +165,14 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { if (item == null) { PlaceholderItem(Color.Unspecified) } else { - val dismissState = - rememberDismissState( - confirmValueChange = { - if (it == DismissedToStart) { - state.eventSink(Remove(item.id)) - // TODO offer an undo option - } - it == DismissedToStart - } - ) + val dismissState = rememberDismissState(confirmValueChange = { it == DismissedToStart }) + + if (dismissState.currentValue == DismissedToStart) { + // TODO offer an undo option after a pause? + // TODO no exit animation yet https://issuetracker.google.com/issues/150812265#comment30 + state.eventSink(Remove(item.id)) + } + val serviceMeta = remember(item.serviceId) { state.serviceMetaMap[item.serviceId] } val themeColorRes = remember(serviceMeta) { serviceMeta?.themeColor } val themeColor = themeColorRes?.let { colorResource(it) } ?: Color.Unspecified @@ -227,7 +223,6 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { } }, dismissContent = { - // TODO where's the elevation on press/drag? val clickUrl = item.clickUrl if (clickUrl != null) { ClickableItem( From 372ad9c37f4929fc10ad29b2cfa544b8d70934ea Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 17:31:20 -0400 Subject: [PATCH 17/32] Animate bookmark icon on changes --- .../kotlin/catchup/app/home/HomeScreen.kt | 45 ++++++++++++------- .../catchup/bookmarks/BookmarkRepository.kt | 7 +++ .../main/kotlin/catchup/compose/Wigglable.kt | 39 ++++++++++++++++ 3 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt diff --git a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt index 185069077..8999face0 100644 --- a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt +++ b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -41,6 +40,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -70,10 +70,12 @@ import catchup.app.service.ServiceScreen import catchup.app.service.bookmarks.Bookmark import catchup.app.service.bookmarks.BookmarksScreen import catchup.app.ui.activity.SettingsScreen +import catchup.bookmarks.BookmarkRepository import catchup.compose.LocalDisplayFeatures import catchup.compose.LocalDynamicTheme import catchup.compose.LocalScrollToTop import catchup.compose.MutableScrollToTop +import catchup.compose.Wigglable import catchup.compose.rememberStableCoroutineScope import catchup.deeplink.DeepLinkable import catchup.di.AppScope @@ -125,6 +127,7 @@ object HomeScreen : Screen, DeepLinkable { val serviceMetas: ImmutableList, val changelogAvailable: Boolean, val selectedIndex: Int, + val bookmarksCount: Long, val eventSink: (Event) -> Unit = {} ) : CircuitUiState @@ -148,6 +151,7 @@ constructor( private val serviceMetaMap: Map, private val catchUpPreferences: CatchUpPreferences, private val changelogHelper: ChangelogHelper, + private val bookmarkRepository: BookmarkRepository ) : Presenter { @CircuitInject(HomeScreen::class, AppScope::class) @@ -179,12 +183,16 @@ constructor( val context = LocalContext.current val changelogAvailable by changelogHelper.changelogAvailable(context).collectAsState(false) + val countFlow = remember { bookmarkRepository.bookmarksCountFlow() } + val bookmarksCount by countFlow.collectAsState(0L) + val scope = rememberStableCoroutineScope() val overlayHost = LocalOverlayHost.current return State( serviceMetas = serviceMetas, changelogAvailable = changelogAvailable, selectedIndex = selectedIndex, + bookmarksCount = bookmarksCount ) { event -> when (event) { OpenSettings -> { @@ -292,7 +300,6 @@ fun Home(state: State, modifier: Modifier = Modifier) { @OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, - ExperimentalLayoutApi::class ) @Composable fun HomePager(state: State, modifier: Modifier = Modifier) { @@ -366,6 +373,8 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { val nestedScrollModifier = remember { modifier.nestedScroll(scrollBehavior.nestedScrollConnection) } + val serviceMetas by rememberUpdatedState(state.serviceMetas) + val eventSink by rememberUpdatedState(state.eventSink) Scaffold( modifier = nestedScrollModifier, contentWindowInsets = WindowInsets(0, 0, 0, 0), @@ -378,18 +387,24 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { actions = { // TODO wire with Syllabus if (state.changelogAvailable) { - ChangelogButton { state.eventSink(ShowChangelog) } + ChangelogButton { eventSink(ShowChangelog) } } - IconButton( - onClick = { state.eventSink(OpenBookmarks) }, - ) { - Icon( - imageVector = Icons.Filled.Bookmark, - contentDescription = "Bookmarks", - ) + + if (state.bookmarksCount > 0) { + // TODO only wiggle on increment? + Wigglable(state.bookmarksCount) { + IconButton( + onClick = { eventSink(OpenBookmarks) }, + ) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = "Bookmarks", + ) + } + } } IconButton( - onClick = { state.eventSink(OpenSettings) }, + onClick = { eventSink(OpenSettings) }, ) { Icon( imageVector = Icons.Default.Settings, @@ -425,7 +440,7 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { ) { // Add tabs for all of our pages val coroutineScope = rememberCoroutineScope() - state.serviceMetas.forEachIndexed { index, serviceMeta -> + serviceMetas.forEachIndexed { index, serviceMeta -> Tab( icon = { Icon( @@ -459,7 +474,7 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { HorizontalPager( modifier = Modifier.weight(1f), beyondBoundsPageCount = 1, - key = { state.serviceMetas[it].id }, + key = { serviceMetas[it].id }, state = pagerState, verticalAlignment = Alignment.Top, ) { page -> @@ -468,8 +483,8 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { LocalScrollToTop provides scrollToTop.takeIf { pagerState.currentPage == page } ) { CircuitContent( - screen = ServiceScreen(state.serviceMetas[page].id), - onNavEvent = { state.eventSink(NestedNavEvent(it)) } + screen = ServiceScreen(serviceMetas[page].id), + onNavEvent = { eventSink(NestedNavEvent(it)) } ) } } diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index cfbfb5297..9ff914463 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.datetime.Clock.System @@ -25,6 +26,8 @@ interface BookmarkRepository { fun isBookmarked(id: Long): Flow + fun bookmarksCountFlow(): Flow + // Exposed to create a PagingSource fun bookmarksCountQuery(): Query @@ -60,6 +63,10 @@ internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : B override fun isBookmarked(id: Long) = bookmarks.map { id in it } + override fun bookmarksCountFlow(): Flow { + return bookmarksCountQuery().asFlow().map { it.executeAsOne() }.flowOn(Dispatchers.IO) + } + override fun bookmarksCountQuery() = database.bookmarksQueries.bookmarkedItemsCount() override fun bookmarksTransacter(): Transacter = database.bookmarksQueries diff --git a/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt b/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt new file mode 100644 index 000000000..3d0fc1475 --- /dev/null +++ b/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt @@ -0,0 +1,39 @@ +package catchup.compose + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer + +@Composable +fun Wigglable(target: T, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + var triggerWiggle by remember { mutableStateOf(false) } + + LaunchedEffect(target) { triggerWiggle = true } + + // TODO make this better + val angle by + animateFloatAsState( + targetValue = if (triggerWiggle) 10f else 0f, + animationSpec = + tween( + durationMillis = 200, + ), + label = "Wiggle" + ) + + LaunchedEffect(angle) { + if (angle == 10f) { + triggerWiggle = false + } + } + + Box(modifier = modifier.graphicsLayer(rotationZ = angle)) { content() } +} From ca5c862fa3837f242bba23d0855a89ffc7f1ad68 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 22:25:45 -0400 Subject: [PATCH 18/32] Fill height --- .../kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index 5f71d97f4..b7520df02 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -153,7 +154,7 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { }, ) { innerPadding -> // TODO empty state, but if I do an if/else check on itemCount the swipe dismiss throws an ISE - LazyColumn(Modifier.padding(innerPadding)) { + LazyColumn(Modifier.padding(innerPadding).fillMaxHeight()) { items( count = state.items.itemCount, // Here we use the new itemKey extension on LazyPagingItems to From 4e3644da53e26bd0d2a387f762480605f0b6f870 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 22:28:39 -0400 Subject: [PATCH 19/32] Ignore sorting in bookmarks --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 09513e425..d3b834ce9 100755 --- a/gradle.properties +++ b/gradle.properties @@ -66,7 +66,7 @@ slack.latestCompileSdkWithSources=34 slack.location.slack-platform=:platform # Dependency sorter doesn't really work on KMP projects -slack.sortDependencies.ignore=:service-api +slack.sortDependencies.ignore=:service-api,:bookmarks #slack.detekt.configs=config/detekt/detekt.yml slack.detekt.baseline=config/detekt/baseline.xml From a6fcf22fe4d5793b2ebfe3663c7272e8accacdee Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 22:42:47 -0400 Subject: [PATCH 20/32] Scale up in wiggle too --- .../main/kotlin/catchup/compose/Wigglable.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt b/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt index 3d0fc1475..40a36b4e2 100644 --- a/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt +++ b/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt @@ -3,6 +3,7 @@ package catchup.compose import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -10,15 +11,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.graphicsLayer @Composable -fun Wigglable(target: T, modifier: Modifier = Modifier, content: @Composable () -> Unit) { +fun Wigglable( + target: T, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { var triggerWiggle by remember { mutableStateOf(false) } LaunchedEffect(target) { triggerWiggle = true } - // TODO make this better val angle by animateFloatAsState( targetValue = if (triggerWiggle) 10f else 0f, @@ -29,11 +34,24 @@ fun Wigglable(target: T, modifier: Modifier = Modifier, content: @Composable label = "Wiggle" ) + val scale by + animateFloatAsState( + targetValue = if (triggerWiggle) 1.2f else 1f, + animationSpec = + tween( + durationMillis = 200, + ), + label = "Wiggle" + ) + LaunchedEffect(angle) { if (angle == 10f) { triggerWiggle = false } } - Box(modifier = modifier.graphicsLayer(rotationZ = angle)) { content() } + Box( + modifier = modifier.graphicsLayer(rotationZ = angle).scale(scale), + content = content, + ) } From fcfa22c99836f328f1a8375e9f0da15e4d05e1ce Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 22:49:30 -0400 Subject: [PATCH 21/32] Selectively wiggle --- .../kotlin/catchup/app/home/HomeScreen.kt | 6 ++++-- .../catchup/compose/UnholyComposables.kt | 20 +++++++++++++++++++ .../main/kotlin/catchup/compose/Wigglable.kt | 4 +++- 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 libraries/compose-extensions/src/main/kotlin/catchup/compose/UnholyComposables.kt diff --git a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt index 8999face0..1d54ebe16 100644 --- a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt +++ b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt @@ -391,8 +391,10 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { } if (state.bookmarksCount > 0) { - // TODO only wiggle on increment? - Wigglable(state.bookmarksCount) { + Wigglable( + state.bookmarksCount, + shouldWiggle = { old, new -> new > old }, + ) { IconButton( onClick = { eventSink(OpenBookmarks) }, ) { diff --git a/libraries/compose-extensions/src/main/kotlin/catchup/compose/UnholyComposables.kt b/libraries/compose-extensions/src/main/kotlin/catchup/compose/UnholyComposables.kt new file mode 100644 index 000000000..f147fcab6 --- /dev/null +++ b/libraries/compose-extensions/src/main/kotlin/catchup/compose/UnholyComposables.kt @@ -0,0 +1,20 @@ +package catchup.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember + +/** + * Returns the previous value of [current]. + * + * Adapted from http://www.billjings.net/posts/title/the-unholy-composable/?up=technical + */ +@Composable +fun previous(current: T, initial: R): R { + val lastValue = remember { mutableStateOf(initial) } + return remember(current) { + val previous = lastValue.value + lastValue.value = current + previous + } +} diff --git a/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt b/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt index 40a36b4e2..7cdbd1f30 100644 --- a/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt +++ b/libraries/compose-extensions/src/main/kotlin/catchup/compose/Wigglable.kt @@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.graphicsLayer fun Wigglable( target: T, modifier: Modifier = Modifier, + shouldWiggle: (old: T, new: T) -> Boolean = { old, new -> old != new }, content: @Composable BoxScope.() -> Unit ) { var triggerWiggle by remember { mutableStateOf(false) } - LaunchedEffect(target) { triggerWiggle = true } + val previous = previous(target, target) + LaunchedEffect(target) { triggerWiggle = shouldWiggle(previous, target) } val angle by animateFloatAsState( From 48ab58a54e91bdaf3616bf90b54d36fa077474e9 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 22:58:58 -0400 Subject: [PATCH 22/32] Empty state --- .../app/service/bookmarks/BookmarksScreen.kt | 169 ++++++++++-------- 1 file changed, 90 insertions(+), 79 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index b7520df02..fc42bc14d 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -153,95 +153,106 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { ) }, ) { innerPadding -> - // TODO empty state, but if I do an if/else check on itemCount the swipe dismiss throws an ISE - LazyColumn(Modifier.padding(innerPadding).fillMaxHeight()) { - items( - count = state.items.itemCount, - // Here we use the new itemKey extension on LazyPagingItems to - // handle placeholders automatically, ensuring you only need to provide - // keys for real items - key = state.items.itemKey { it.id }, - ) { index -> - val item = state.items[index] - if (item == null) { - PlaceholderItem(Color.Unspecified) - } else { - val dismissState = rememberDismissState(confirmValueChange = { it == DismissedToStart }) + if (state.items.itemCount == 0) { + Box(Modifier.padding(innerPadding).fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No bookmarks", color = MaterialTheme.colorScheme.onBackground) + } + } else { + BookmarksList(state, Modifier.padding(innerPadding)) + } + } +} - if (dismissState.currentValue == DismissedToStart) { - // TODO offer an undo option after a pause? - // TODO no exit animation yet https://issuetracker.google.com/issues/150812265#comment30 - state.eventSink(Remove(item.id)) - } +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun BookmarksList(state: BookmarksScreen.State, modifier: Modifier = Modifier) { + LazyColumn(modifier.fillMaxHeight()) { + items( + count = state.items.itemCount, + // Here we use the new itemKey extension on LazyPagingItems to + // handle placeholders automatically, ensuring you only need to provide + // keys for real items + key = state.items.itemKey { it.id }, + ) { index -> + val item = state.items[index] + if (item == null) { + PlaceholderItem(Color.Unspecified) + } else { + val dismissState = rememberDismissState(confirmValueChange = { it == DismissedToStart }) - val serviceMeta = remember(item.serviceId) { state.serviceMetaMap[item.serviceId] } - val themeColorRes = remember(serviceMeta) { serviceMeta?.themeColor } - val themeColor = themeColorRes?.let { colorResource(it) } ?: Color.Unspecified - // When swiping from start to end, we don't dismiss and instead use this to indicate - // metadata about the bookmark, like the service it's from. - SwipeToDismiss( - modifier = Modifier.animateItemPlacement(), - state = dismissState, - background = { - val color = - when (dismissState.dismissDirection) { - StartToEnd -> themeColor - EndToStart -> MaterialTheme.colorScheme.error - null -> Color.Unspecified - } - val alignment = - when (dismissState.dismissDirection) { - StartToEnd -> Alignment.CenterStart - EndToStart -> Alignment.CenterEnd - null -> Alignment.CenterStart - } - Box( - modifier = Modifier.fillMaxSize().background(color), - contentAlignment = alignment, - ) { - when (dismissState.dismissDirection) { - StartToEnd -> { - serviceMeta?.let { - Icon( - imageVector = ImageVector.vectorResource(it.icon), - contentDescription = stringResource(it.name), - modifier = Modifier.padding(32.dp).size(32.dp), - tint = Color.White, - ) - } - } - EndToStart -> + if (dismissState.currentValue == DismissedToStart) { + // TODO offer an undo option after a pause? + // TODO no exit animation yet https://issuetracker.google.com/issues/150812265#comment30 + state.eventSink(Remove(item.id)) + } + + val serviceMeta = remember(item.serviceId) { state.serviceMetaMap[item.serviceId] } + val themeColorRes = remember(serviceMeta) { serviceMeta?.themeColor } + val themeColor = themeColorRes?.let { colorResource(it) } ?: Color.Unspecified + // When swiping from start to end, we don't dismiss and instead use this to indicate + // metadata about the bookmark, like the service it's from. + SwipeToDismiss( + modifier = Modifier.animateItemPlacement(), + state = dismissState, + background = { + val color = + when (dismissState.dismissDirection) { + StartToEnd -> themeColor + EndToStart -> MaterialTheme.colorScheme.error + null -> Color.Unspecified + } + val alignment = + when (dismissState.dismissDirection) { + StartToEnd -> Alignment.CenterStart + EndToStart -> Alignment.CenterEnd + null -> Alignment.CenterStart + } + Box( + modifier = Modifier.fillMaxSize().background(color), + contentAlignment = alignment, + ) { + when (dismissState.dismissDirection) { + StartToEnd -> { + serviceMeta?.let { Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete", + imageVector = ImageVector.vectorResource(it.icon), + contentDescription = stringResource(it.name), modifier = Modifier.padding(32.dp).size(32.dp), - tint = MaterialTheme.colorScheme.onError + tint = Color.White, ) - null -> { - // Do nothing } } - } - }, - dismissContent = { - val clickUrl = item.clickUrl - if (clickUrl != null) { - ClickableItem( - modifier = Modifier.animateItemPlacement(), - onClick = { state.eventSink(Click(clickUrl, themeColor.toArgb())) }, - ) { - TextItem(item, themeColor) + EndToStart -> + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.padding(32.dp).size(32.dp), + tint = MaterialTheme.colorScheme.onError + ) + null -> { + // Do nothing } - } else { - TextItem( - item, - themeColor, - modifier = Modifier.animateItemPlacement(), - ) } } - ) - } + }, + dismissContent = { + val clickUrl = item.clickUrl + if (clickUrl != null) { + ClickableItem( + modifier = Modifier.animateItemPlacement(), + onClick = { state.eventSink(Click(clickUrl, themeColor.toArgb())) }, + ) { + TextItem(item, themeColor) + } + } else { + TextItem( + item, + themeColor, + modifier = Modifier.animateItemPlacement(), + ) + } + } + ) } } } From 457015055216ada648264af32552dd1e3b27faf8 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 23:17:42 -0400 Subject: [PATCH 23/32] Animate bookmark icon changes --- app/src/main/kotlin/catchup/app/home/HomeScreen.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt index 1d54ebe16..35c301f00 100644 --- a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt +++ b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt @@ -3,7 +3,10 @@ package catchup.app.home import androidx.activity.compose.ReportDrawnWhen import androidx.annotation.ColorRes import androidx.compose.animation.Animatable +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -390,7 +393,11 @@ fun HomePager(state: State, modifier: Modifier = Modifier) { ChangelogButton { eventSink(ShowChangelog) } } - if (state.bookmarksCount > 0) { + AnimatedVisibility( + state.bookmarksCount > 0, + enter = fadeIn(), + exit = fadeOut(), + ) { Wigglable( state.bookmarksCount, shouldWiggle = { old, new -> new > old }, From a37a6531afedd6f2ada78ad12582b2e2eb81d96a Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 23:22:22 -0400 Subject: [PATCH 24/32] Add export TODO --- .../kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index fc42bc14d..fc65c38bc 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -74,7 +74,7 @@ import kotlinx.parcelize.Parcelize /* * TODO - * - Empty state + * - Export bookmarks */ @ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) From 4f313ab618136b35f9026f409bf0279337baa26a Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 12 Sep 2023 01:07:02 -0400 Subject: [PATCH 25/32] Update bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt Co-authored-by: Bill Phillips --- .../catchup/bookmarks/BookmarkRepository.kt | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index 9ff914463..7066b95c7 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -40,18 +40,9 @@ internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : B private val scope = CoroutineScope(Dispatchers.IO) // Maintain an in-memory cache of all the bookmarks - private val bookmarks = MutableStateFlow(LinkedHashSet()) - - init { - scope.launch { - val idsFlow = - database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } - idsFlow.collect { query -> - // Preserve order - bookmarks.emit(query.executeAsList().mapTo(LinkedHashSet(), Bookmark::id)) - } - } - } +private val bookmarks = database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } + .map { it.executeAsList().mapTo(LinkedHashSet(), Bookmark::id) } + .stateIn(scope, SharingStarted.Eagerly, LinkedHashSet()) override fun addBookmark(id: Long, timestamp: Instant) { scope.launch { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } From d0dc415bfd60abb1ff3d89945ac044bc5a43a57c Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sun, 10 Sep 2023 23:23:23 -0400 Subject: [PATCH 26/32] Another TODO --- .../main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index fc65c38bc..cd86ec68f 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -75,6 +75,7 @@ import kotlinx.parcelize.Parcelize /* * TODO * - Export bookmarks + * - Share individual items on longpress */ @ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) From 7abf66f44030c804de2090c0510443900b808d58 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 12 Sep 2023 01:08:28 -0400 Subject: [PATCH 27/32] Fixes --- .../commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index 7066b95c7..b9f16d554 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -13,8 +13,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.datetime.Clock.System import kotlinx.datetime.Instant @@ -42,7 +44,7 @@ internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : B // Maintain an in-memory cache of all the bookmarks private val bookmarks = database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } .map { it.executeAsList().mapTo(LinkedHashSet(), Bookmark::id) } - .stateIn(scope, SharingStarted.Eagerly, LinkedHashSet()) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) override fun addBookmark(id: Long, timestamp: Instant) { scope.launch { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } From 69ea353e8ab8637e7ff9eb23ff7b4ce89d806972 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 12 Sep 2023 01:16:03 -0400 Subject: [PATCH 28/32] Suspenders --- .../service/bookmarks/BookmarkIconScreen.kt | 13 +++++++++---- .../catchup/bookmarks/BookmarkRepository.kt | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarkIconScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarkIconScreen.kt index 85e664da0..3b94521e9 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarkIconScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarkIconScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color import catchup.app.service.TextActionItem import catchup.app.service.bookmarks.BookmarkIconScreen.State import catchup.bookmarks.BookmarkRepository +import catchup.compose.rememberStableCoroutineScope import catchup.di.AppScope import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.CircuitUiState @@ -18,6 +19,7 @@ import com.slack.circuit.runtime.screen.Screen import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @Parcelize @@ -37,11 +39,14 @@ constructor( @Composable override fun present(): State { val isBookmarked by bookmarkRepository.isBookmarked(screen.id).collectAsState(false) + val scope = rememberStableCoroutineScope() return State(isBookmarked, themeColor) { - if (isBookmarked) { - bookmarkRepository.removeBookmark(screen.id) - } else { - bookmarkRepository.addBookmark(screen.id) + scope.launch { + if (isBookmarked) { + bookmarkRepository.removeBookmark(screen.id) + } else { + bookmarkRepository.addBookmark(screen.id) + } } } } diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index b9f16d554..422ea9d7b 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -18,13 +18,14 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.Clock.System import kotlinx.datetime.Instant interface BookmarkRepository { - fun addBookmark(id: Long, timestamp: Instant = System.now()) + suspend fun addBookmark(id: Long, timestamp: Instant = System.now()) - fun removeBookmark(id: Long) + suspend fun removeBookmark(id: Long) fun isBookmarked(id: Long): Flow @@ -38,20 +39,22 @@ interface BookmarkRepository { fun bookmarksQuery(limit: Long, offset: Long): Query } -internal class BookmarkRepositoryImpl(private val database: CatchUpDatabase) : BookmarkRepository { - private val scope = CoroutineScope(Dispatchers.IO) +internal class BookmarkRepositoryImpl( + private val database: CatchUpDatabase, + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) : BookmarkRepository { // Maintain an in-memory cache of all the bookmarks private val bookmarks = database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } .map { it.executeAsList().mapTo(LinkedHashSet(), Bookmark::id) } .stateIn(scope, SharingStarted.Eagerly, emptySet()) - override fun addBookmark(id: Long, timestamp: Instant) { - scope.launch { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } + override suspend fun addBookmark(id: Long, timestamp: Instant) { + withContext(Dispatchers.IO) { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } } - override fun removeBookmark(id: Long) { - scope.launch { database.transaction { database.bookmarksQueries.removeBookmark(id) } } + override suspend fun removeBookmark(id: Long) { + withContext(Dispatchers.IO) { database.transaction { database.bookmarksQueries.removeBookmark(id) } } } override fun isBookmarked(id: Long) = bookmarks.map { id in it } From b7c40acec2bd3c62b6c7605ee2804f60df298a4e Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 12 Sep 2023 01:19:32 -0400 Subject: [PATCH 29/32] Spotless --- .../catchup/bookmarks/BookmarkRepository.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt index 422ea9d7b..84d430deb 100644 --- a/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt +++ b/bookmarks/src/commonMain/kotlin/catchup/bookmarks/BookmarkRepository.kt @@ -12,12 +12,10 @@ import catchup.service.api.toCatchUpItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock.System import kotlinx.datetime.Instant @@ -45,16 +43,22 @@ internal class BookmarkRepositoryImpl( ) : BookmarkRepository { // Maintain an in-memory cache of all the bookmarks -private val bookmarks = database.transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } - .map { it.executeAsList().mapTo(LinkedHashSet(), Bookmark::id) } - .stateIn(scope, SharingStarted.Eagerly, emptySet()) + private val bookmarks = + database + .transactionWithResult { database.bookmarksQueries.bookmarkIds().asFlow() } + .map { it.executeAsList().mapTo(LinkedHashSet(), Bookmark::id) } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) override suspend fun addBookmark(id: Long, timestamp: Instant) { - withContext(Dispatchers.IO) { database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } } + withContext(Dispatchers.IO) { + database.transaction { database.bookmarksQueries.addBookmark(id, timestamp) } + } } override suspend fun removeBookmark(id: Long) { - withContext(Dispatchers.IO) { database.transaction { database.bookmarksQueries.removeBookmark(id) } } + withContext(Dispatchers.IO) { + database.transaction { database.bookmarksQueries.removeBookmark(id) } + } } override fun isBookmarked(id: Long) = bookmarks.map { id in it } From ddb9ba8b8b02d8469f8fe80ae175155d8267db43 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 12 Sep 2023 01:39:35 -0400 Subject: [PATCH 30/32] Another update --- .../kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index cd86ec68f..8a82783c2 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -126,7 +126,9 @@ constructor( return BookmarksScreen.State(lazyItems, metaMap) { event -> when (event) { is Remove -> { - bookmarksRepository.removeBookmark(event.id) + scope.launch { + bookmarksRepository.removeBookmark(event.id) + } } is Click -> { scope.launch { From 15e00426c98d82603f6b11032dc3067c14876d8c Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 12 Sep 2023 02:11:51 -0400 Subject: [PATCH 31/32] Add export option --- .../catchup/app/service/ServiceScreen.kt | 2 +- .../app/service/bookmarks/BookmarksScreen.kt | 80 ++++++++++++++++--- libraries/util/src/main/AndroidManifest.xml | 13 +++ .../kotlin/catchup/util/share/FileSharing.kt | 20 +++++ .../main/res/xml/catchup_util_file_paths.xml | 4 + 5 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 libraries/util/src/main/AndroidManifest.xml create mode 100644 libraries/util/src/main/kotlin/catchup/util/share/FileSharing.kt create mode 100644 libraries/util/src/main/res/xml/catchup_util_file_paths.xml diff --git a/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt b/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt index be95ce8a9..0e548bfee 100644 --- a/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/ServiceScreen.kt @@ -221,7 +221,7 @@ constructor( val shareIntent = Intent().apply { action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, url) + putExtra(Intent.EXTRA_TEXT, "${event.item.title}\n\n${event.item.clickUrl}") type = "text/plain" } navigator.goTo(IntentScreen(Intent.createChooser(shareIntent, "Share"))) diff --git a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt index 8a82783c2..327110368 100644 --- a/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt +++ b/app/src/main/kotlin/catchup/app/service/bookmarks/BookmarksScreen.kt @@ -1,5 +1,7 @@ package catchup.app.service.bookmarks +import android.content.Context +import android.content.Intent import androidx.annotation.ColorInt import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -12,12 +14,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.DismissDirection.EndToStart import androidx.compose.material3.DismissDirection.StartToEnd import androidx.compose.material3.DismissValue.Default import androidx.compose.material3.DismissValue.DismissedToStart import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SwipeToDismiss @@ -50,6 +54,7 @@ import catchup.app.service.TextItem import catchup.app.service.UrlMeta import catchup.app.service.bookmarks.BookmarksScreen.Event.Click import catchup.app.service.bookmarks.BookmarksScreen.Event.Remove +import catchup.app.service.bookmarks.BookmarksScreen.Event.Share import catchup.base.ui.BackPressNavButton import catchup.bookmarks.BookmarkRepository import catchup.compose.rememberStableCoroutineScope @@ -57,27 +62,30 @@ import catchup.deeplink.DeepLinkable import catchup.di.AppScope import catchup.service.api.CatchUpItem import catchup.service.api.ServiceMeta +import catchup.util.share.createFileShareIntent import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen +import com.slack.circuitx.android.IntentScreen import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.multibindings.StringKey import dev.zacsweers.catchup.R.string -import javax.inject.Inject +import java.nio.file.Path +import kotlin.io.path.bufferedWriter +import kotlin.io.path.createTempFile import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize -/* - * TODO - * - Export bookmarks - * - Share individual items on longpress - */ - @ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) @StringKey("bookmarks") @Parcelize @@ -94,18 +102,27 @@ object BookmarksScreen : Screen, DeepLinkable { data class Click(val url: String, @ColorInt val themeColor: Int) : Event data class Remove(val id: Long) : Event + + data object Share : Event } } -@CircuitInject(BookmarksScreen::class, AppScope::class) @OptIn(ExperimentalPagingApi::class) class BookmarksPresenter -@Inject +@AssistedInject constructor( + @Assisted private val navigator: Navigator, private val bookmarksRepository: BookmarkRepository, private val linkManager: LinkManager, private val serviceMetaMap: Map, ) : Presenter { + + @CircuitInject(BookmarksScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): BookmarksPresenter + } + @Composable override fun present(): BookmarksScreen.State { val itemsFlow = remember { @@ -126,9 +143,7 @@ constructor( return BookmarksScreen.State(lazyItems, metaMap) { event -> when (event) { is Remove -> { - scope.launch { - bookmarksRepository.removeBookmark(event.id) - } + scope.launch { bookmarksRepository.removeBookmark(event.id) } } is Click -> { scope.launch { @@ -136,13 +151,44 @@ constructor( linkManager.openUrl(meta) } } + Share -> scope.launch { shareAll(context, metaMap) } } } } + + private suspend fun shareAll( + context: Context, + serviceMetaMap: ImmutableMap, + ) { + val intent = + withContext(Dispatchers.IO) { + val items = bookmarksRepository.bookmarksQuery(Long.MAX_VALUE, 0).executeAsList() + val path = writeItemsToPath(context, items, serviceMetaMap) + createFileShareIntent(context, path.toFile(), "text/csv") + } + val chooser = Intent.createChooser(intent, "Share bookmarks") + navigator.goTo(IntentScreen(chooser)) + } + + private fun writeItemsToPath( + context: Context, + items: List, + serviceMetaMap: ImmutableMap, + ): Path { + val path = createTempFile(context.cacheDir.toPath(), "bookmarks", ".csv") + path.bufferedWriter().use { writer -> + writer.appendLine("Title,URL,Service") + items.forEach { item -> + val service = serviceMetaMap[item.serviceId]?.name ?: item.serviceId + writer.appendLine("${item.title},${item.clickUrl},$service") + } + } + return path + } } @CircuitInject(BookmarksScreen::class, AppScope::class) -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { Scaffold( @@ -153,6 +199,14 @@ fun Bookmarks(state: BookmarksScreen.State, modifier: Modifier = Modifier) { TopAppBar( title = { Text(stringResource(string.title_bookmarks)) }, navigationIcon = { BackPressNavButton() }, + actions = { + IconButton({ state.eventSink(Share) }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + ) + } + }, ) }, ) { innerPadding -> diff --git a/libraries/util/src/main/AndroidManifest.xml b/libraries/util/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f610240a2 --- /dev/null +++ b/libraries/util/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/libraries/util/src/main/kotlin/catchup/util/share/FileSharing.kt b/libraries/util/src/main/kotlin/catchup/util/share/FileSharing.kt new file mode 100644 index 000000000..c49818312 --- /dev/null +++ b/libraries/util/src/main/kotlin/catchup/util/share/FileSharing.kt @@ -0,0 +1,20 @@ +package catchup.util.share + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +fun createFileShareIntent( + context: Context, + file: File, + intentType: String, +): Intent { + val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + + return Intent(Intent.ACTION_SEND).apply { + type = intentType + putExtra(Intent.EXTRA_STREAM, fileUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } +} diff --git a/libraries/util/src/main/res/xml/catchup_util_file_paths.xml b/libraries/util/src/main/res/xml/catchup_util_file_paths.xml new file mode 100644 index 000000000..2945bbd2e --- /dev/null +++ b/libraries/util/src/main/res/xml/catchup_util_file_paths.xml @@ -0,0 +1,4 @@ + + + + From 476f0f1cd64f08839c4fec148e14a520f658783b Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 12 Sep 2023 02:14:36 -0400 Subject: [PATCH 32/32] Posterity note --- app/src/main/kotlin/catchup/app/home/HomeScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt index 35c301f00..3ca18b640 100644 --- a/app/src/main/kotlin/catchup/app/home/HomeScreen.kt +++ b/app/src/main/kotlin/catchup/app/home/HomeScreen.kt @@ -120,6 +120,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +// TODO generalize metas to allow dynamic ones, like settings/bookmarks @ContributesMultibinding(AppScope::class, boundType = DeepLinkable::class) @StringKey("home") @Parcelize