diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 6ce2e2e..44f111b 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -80,6 +80,7 @@ android { buildTypes { getByName("release") { isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") } } compileOptions { diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index 5cbac7b..7dff5e3 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -307,9 +307,24 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP } } +actual suspend fun clearLibraryReadingPosition(fileId: String) = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + val file = db.files.get(fileId) ?: return@useLibrary + val clusterId = file.bodyClusterId ?: return@useLibrary + db.readingStates.deleteForBodyCluster(clusterId) + } + Unit +} + actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) { openLibraryDatabase().useLibrary { db -> - db.files.updateReadingStatus(fileId, status) + db.transaction { + if (status == BookReadingStatus.NEW) { + val file = files.get(fileId) ?: return@transaction false + file.bodyClusterId?.let { readingStates.deleteForBodyCluster(it) } + } + files.updateReadingStatus(fileId, status) + } } } diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml index bda706c..66ffb4b 100644 --- a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -1,30 +1,65 @@ + + - - - - - - - - - - \ No newline at end of file + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml index 3c40f44..c2ab5ef 100644 --- a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +++ b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml @@ -1,170 +1,10 @@ + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + android:fillColor="#0d2535" + android:pathData="M0,0h108v108h-108z"/> + diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png index a571e60..f69e95f 100644 Binary files a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png index 61da551..f69e95f 100644 Binary files a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png index c41dd28..2257963 100644 Binary files a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png index db5080a..2257963 100644 Binary files a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png index 6dba46d..7b53c00 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png index da31a87..7b53c00 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png index 15ac681..568881a 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png index b216f2d..568881a 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png index f25a419..e0eac22 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png index e96783c..e0eac22 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 0b834ce..2fe6352 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -118,7 +118,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) { ) is AppState.Reader -> { scope.launch { saveActiveReadingFileId(null) } - AppState.Library(current.libraryItems, current.scanPath, current.message) + AppState.Library(emptyList(), current.scanPath, current.message) } is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message) is AppState.Error -> AppState.LoadingLibrary diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 490c9d3..f670fff 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -116,6 +116,8 @@ expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) +expect suspend fun clearLibraryReadingPosition(fileId: String) + expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean expect suspend fun shareLibraryBookFile(fileId: String): Boolean diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 0e942aa..722a2e8 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -146,6 +146,28 @@ internal fun LibraryScreen( } } + suspend fun applyReadingStatus( + item: LibraryItem, + status: BookReadingStatus, + successMessage: String, + ) { + if (markLibraryReadingStatus(item.fileId, status)) { + val updatedItem = loadLibraryItem(item.fileId) ?: item.copy(readingStatus = status) + items = items.replaceLibraryItem(updatedItem) + searchResults = searchResults.replaceLibraryItem(updatedItem) + message = successMessage + if (searchActive) { + searching = true + searchResults = searchLibraryItems(searchText, SearchResultLimit) + searching = false + } else { + loadPage(reset = true) + } + } else { + message = "Could not update ${item.title}." + } + } + fun rescanAllLibrary() { settingsMenuOpen = false scope.launch { @@ -320,7 +342,13 @@ internal fun LibraryScreen( readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem) coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId) } - markLibraryReadingStatus(item.fileId, BookReadingStatus.READING) + if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)) { + val readingItem = loadLibraryItem(item.fileId) + ?: item.copy(readingStatus = BookReadingStatus.READING) + items = items.replaceLibraryItem(readingItem) + searchResults = searchResults.replaceLibraryItem(readingItem) + readerLibraryItems = readerLibraryItems.replaceLibraryItem(readingItem) + } saveActiveReadingFileId(item.fileId) AppState.Reader( fileId = item.fileId, @@ -342,12 +370,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) { - message = "Marked ${item.title} as read." - refresh() - } else { - message = "Could not update ${item.title}." - } + applyReadingStatus(item, BookReadingStatus.READ, "Marked ${item.title} as read.") } finally { busy = false } @@ -357,12 +380,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) { - message = "Marked ${item.title} as unread." - refresh() - } else { - message = "Could not update ${item.title}." - } + applyReadingStatus(item, BookReadingStatus.NEW, "Marked ${item.title} as unread.") } finally { busy = false } @@ -372,12 +390,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) { - message = "Removed marks from ${item.title}." - refresh() - } else { - message = "Could not update ${item.title}." - } + applyReadingStatus(item, BookReadingStatus.NEW, "Removed marks from ${item.title}.") } finally { busy = false } @@ -387,12 +400,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) { - message = "Marked ${item.title} as not interested." - refresh() - } else { - message = "Could not update ${item.title}." - } + applyReadingStatus(item, BookReadingStatus.NOT_INTERESTED, "Marked ${item.title} as not interested.") } finally { busy = false } @@ -438,7 +446,8 @@ internal fun LibraryScreen( librarySection( key = "reading", title = "reading now", - count = readingNow.size, + itemCount = readingNow.size, + displayCount = readingNow.size.takeIf { endReached }, collapsed = readingNowCollapsed, onCollapsedChange = { readingNowCollapsed = it }, ) { @@ -447,7 +456,8 @@ internal fun LibraryScreen( librarySection( key = "library", title = "my library", - count = myLibrary.size, + itemCount = myLibrary.size, + displayCount = myLibrary.size.takeIf { endReached }, collapsed = myLibraryCollapsed, onCollapsedChange = { myLibraryCollapsed = it }, ) { @@ -456,7 +466,8 @@ internal fun LibraryScreen( librarySection( key = "not-interested", title = "not interested", - count = notInterested.size, + itemCount = notInterested.size, + displayCount = notInterested.size.takeIf { endReached }, collapsed = notInterestedCollapsed, onCollapsedChange = { notInterestedCollapsed = it }, ) { @@ -573,16 +584,17 @@ private fun EmptySearchPane(modifier: Modifier = Modifier) { private fun LazyListScope.librarySection( key: String, title: String, - count: Int, + itemCount: Int, + displayCount: Int?, collapsed: Boolean, onCollapsedChange: (Boolean) -> Unit, content: LazyListScope.() -> Unit, ) { - if (count == 0) return + if (itemCount == 0) return item(key = "section-$key") { LibrarySectionHeader( text = title, - count = count, + count = displayCount, collapsed = collapsed, onToggle = { onCollapsedChange(!collapsed) }, ) @@ -595,7 +607,7 @@ private fun LazyListScope.librarySection( @Composable private fun LibrarySectionHeader( text: String, - count: Int, + count: Int?, collapsed: Boolean, onToggle: () -> Unit, ) { @@ -614,7 +626,7 @@ private fun LibrarySectionHeader( modifier = Modifier.size(18.dp), ) Text( - "$text ($count)", + if (count == null) text else "$text ($count)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, textAlign = TextAlign.Center, diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index 305d413..52c3822 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -255,9 +255,24 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP } } +actual suspend fun clearLibraryReadingPosition(fileId: String) = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + val file = db.files.get(fileId) ?: return@useLibrary + val clusterId = file.bodyClusterId ?: return@useLibrary + db.readingStates.deleteForBodyCluster(clusterId) + } + Unit +} + actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) { openLibraryDatabase().useLibrary { db -> - db.files.updateReadingStatus(fileId, status) + db.transaction { + if (status == BookReadingStatus.NEW) { + val file = files.get(fileId) ?: return@transaction false + file.bodyClusterId?.let { readingStates.deleteForBodyCluster(it) } + } + files.updateReadingStatus(fileId, status) + } } } diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index 12c0f4a..6386843 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -49,6 +49,8 @@ actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit +actual suspend fun clearLibraryReadingPosition(fileId: String) = Unit + actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false diff --git a/image_src/book.svg b/image_src/book.svg new file mode 100644 index 0000000..95a8f62 --- /dev/null +++ b/image_src/book.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TOREAD + + + E-READER + + + + diff --git a/image_src/favicon.svg b/image_src/favicon.svg new file mode 100644 index 0000000..dc3ba27 --- /dev/null +++ b/image_src/favicon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/image_src/icon-app.svg b/image_src/icon-app.svg new file mode 100644 index 0000000..96896f3 --- /dev/null +++ b/image_src/icon-app.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TOREAD + + E-READER + + + + diff --git a/image_src/playstore-feature.svg b/image_src/playstore-feature.svg new file mode 100644 index 0000000..d96b967 --- /dev/null +++ b/image_src/playstore-feature.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TOREAD + + + Your e-reading companion + + + · EPUB & FB2 support + · Clean reader, smart library + · Read aloud & offline + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TOREAD + + E-READER + + + diff --git a/image_src/star-colored.svg b/image_src/star-colored.svg new file mode 100644 index 0000000..338ca23 --- /dev/null +++ b/image_src/star-colored.svg @@ -0,0 +1,51 @@ + + + + + + + + + diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt index 1a0ab48..e6de8ff 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt @@ -217,6 +217,8 @@ interface ReadingStateRepository { fun upsert(state: ReadingStateRecord) fun get(id: String): ReadingStateRecord? fun getForBodyCluster(bodyClusterId: String): ReadingStateRecord? + fun deleteForBodyCluster(bodyClusterId: String): Int + fun delete(id: String): Boolean } interface BookmarkRepository { diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt index a1f168f..f9c12fb 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt @@ -862,6 +862,15 @@ private class JdbcReadingStateRepository(private val connection: Connection) : R it.toReadingStateRecord() } } + + override fun deleteForBodyCluster(bodyClusterId: String): Int { + return connection.prepareStatement("DELETE FROM reading_states WHERE body_cluster_id = ?").use { statement -> + statement.setString(1, bodyClusterId) + statement.executeUpdate() + } + } + + override fun delete(id: String): Boolean = connection.deleteById("reading_states", id) } private class JdbcBookmarkRepository(private val connection: Connection) : BookmarkRepository { diff --git a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt index bbaca71..e500d7b 100644 --- a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt +++ b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt @@ -376,6 +376,42 @@ class H2LibraryDatabaseTest { H2LibraryDatabase.openFile(path).close() } + @Test + fun persistsReadingStatusAcrossDatabaseRestart() { + val path = Files.createTempDirectory("toread-h2-reading-status-").resolve("library").toString() + val now = 1_700_000_000_000L + + val initialDb = H2LibraryDatabase.openFile(path) + try { + val db = initialDb + db.transaction { + books.upsert(BookRecord(id = "book-1", title = "Persistent", createdAt = now, updatedAt = now)) + files.upsert( + BookFileRecord( + id = "file-1", + bookId = "book-1", + rawSha256 = "sha-1", + storageKind = BookFileStorageKind.EXTERNAL_URI, + createdAt = now, + updatedAt = now, + ) + ) + } + assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.NOT_INTERESTED)) + } finally { + initialDb.close() + } + + val reopenedDb = H2LibraryDatabase.openFile(path) + try { + val db = reopenedDb + assertEquals(BookReadingStatus.NOT_INTERESTED, db.files.get("file-1")?.readingStatus) + assertEquals(BookReadingStatus.NOT_INTERESTED, db.files.getLibraryFile("file-1")?.readingStatus) + } finally { + reopenedDb.close() + } + } + @Test fun migratesLegacyBookFilesWithImportTime() { val path = Files.createTempDirectory("toread-h2-imported-at-").resolve("library").toString()