improved UI

This commit is contained in:
sergeych committed 2026-05-17 01:56:33 +03:00
1 parent 02a40c2589
commit d8b39057d5
8 files changed
+460 -16

No files matched your search

@@ -13,6 +13,7 @@ import android.provider.OpenableColumns
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ReadingStateRecord
@@ -20,6 +21,9 @@ import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
import net.sergeych.toread.storage.jdbc.LibraryScanSummary
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -154,6 +158,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
val now = System.currentTimeMillis()
db.readingStates.upsert(
ReadingStateRecord(
id = "state-$clusterId",
@@ -163,9 +168,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(),
),
updatedAt = System.currentTimeMillis(),
updatedAt = now,
),
)
db.files.touchLastReadAt(fileId, now)
}
}
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.updateReadingStatus(fileId, status)
}
}
@@ -243,6 +255,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
actual fun formatLibraryLastReadTime(millis: Long): String =
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis))
private data class AndroidLibraryDocument(
val uri: Uri,
val name: String,
@@ -432,6 +447,8 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
sizeBytes = sizeBytes,
storageUri = storageUri,
lastSeenAt = lastSeenAt,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
)
private fun libraryLogFile(): File =
@@ -31,9 +31,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Scanner
@@ -42,9 +42,12 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -99,6 +102,7 @@ import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.fb2.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan
import net.sergeych.toread.fb2.Fb2TextStyle
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.text.HyphenationRegistry
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -210,7 +214,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
)
},
onBack = {
state = AppState.Library(current.libraryItems, current.scanPath, current.message)
state = AppState.Library(emptyList(), current.scanPath, current.message)
},
)
is AppState.BookInfo -> BookInfoScreen(
@@ -317,7 +321,20 @@ private fun LibraryScreen(
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(items, key = { it.fileId }) { item ->
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
if (hasReadingNow) {
item(key = "section-reading") {
LibrarySectionHeader("reading now")
}
}
itemsIndexed(items, key = { _, item -> item.fileId }) { index, item ->
if (
hasReadingNow &&
item.readingStatus != BookReadingStatus.READING &&
(index == 0 || items[index - 1].readingStatus == BookReadingStatus.READING)
) {
LibrarySectionHeader("my library")
}
LibraryRow(
item = item,
coverCache = coverCache,
@@ -329,6 +346,7 @@ private fun LibraryScreen(
val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
saveActiveReadingFileId(item.fileId)
AppState.Reader(
fileId = item.fileId,
@@ -346,6 +364,51 @@ private fun LibraryScreen(
}
}
},
onMarkAsRead = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
message = "Marked ${item.title} as read."
loadPage(reset = true)
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onMarkAsUnread = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Marked ${item.title} as unread."
loadPage(reset = true)
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onNotInterested = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
message = "Marked ${item.title} as not interesting."
loadPage(reset = true)
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onDelete = {
scope.launch {
busy = true
@@ -525,14 +588,30 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
}
}
@Composable
private fun LibrarySectionHeader(text: String) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp),
)
}
@Composable
private fun LibraryRow(
item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean,
onOpen: () -> Unit,
onMarkAsRead: () -> Unit,
onMarkAsUnread: () -> Unit,
onNotInterested: () -> Unit,
onDelete: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
@@ -563,8 +642,53 @@ private fun LibraryRow(
maxLines = 1,
)
}
IconButton(onClick = onDelete, enabled = enabled) {
Icon(Icons.Filled.Delete, contentDescription = "Remove ${item.title}")
Box {
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
Icon(Icons.Filled.MoreVert, contentDescription = "Book menu for ${item.title}")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Open") },
onClick = {
menuOpen = false
onOpen()
},
)
HorizontalDivider()
if (item.readingStatus != BookReadingStatus.READ) {
DropdownMenuItem(
text = { Text("Mark as read") },
onClick = {
menuOpen = false
onMarkAsRead()
},
)
}
if (item.readingStatus == BookReadingStatus.READ) {
DropdownMenuItem(
text = { Text("Mark as unread") },
onClick = {
menuOpen = false
onMarkAsUnread()
},
)
}
DropdownMenuItem(
text = { Text("Not interesting") },
onClick = {
menuOpen = false
onNotInterested()
},
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
menuOpen = false
onDelete()
},
)
}
}
}
}
@@ -621,6 +745,11 @@ private fun BookView(
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
LaunchedEffect(fileId) {
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
}
LaunchedEffect(fileId) {
loadLibraryReadingPosition(fileId)?.let { position ->
@@ -639,6 +768,19 @@ private fun BookView(
.collect { saveLibraryReadingPosition(fileId, it) }
}
LaunchedEffect(fileId, listState) {
snapshotFlow {
val layoutInfo = listState.layoutInfo
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
layoutInfo.totalItemsCount > 0 && lastVisible >= layoutInfo.totalItemsCount - 1
}
.filter { restored && it && !markedRead }
.collect {
markedRead = true
markLibraryReadingStatus(fileId, BookReadingStatus.READ)
}
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
@@ -1399,12 +1541,24 @@ private val ThemeMode.displayName: String
private fun LibraryItem.libraryMetadataLine(): String =
listOfNotNull(
readingStatus.displayLabel,
lastReadAt?.formatLastRead(),
date?.yearOrRaw(),
language?.uppercase(),
format?.uppercase(),
sizeBytes?.formatBytes(),
).joinToString(" | ").ifBlank { "No metadata" }
private val BookReadingStatus.displayLabel: String
get() = when (this) {
BookReadingStatus.NEW -> "New"
BookReadingStatus.READING -> "Reading"
BookReadingStatus.READ -> "Read"
BookReadingStatus.NOT_INTERESTED -> "Not interested"
}
private fun Long.formatLastRead(): String = "Last read ${formatLibraryLastReadTime(this)}"
private fun String.yearOrRaw(): String =
Regex("""\d{4}""").find(this)?.value ?: this
@@ -2,6 +2,7 @@ package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.storage.BookReadingStatus
data class LibraryItem(
val fileId: String,
@@ -14,6 +15,8 @@ data class LibraryItem(
val sizeBytes: Long?,
val storageUri: String?,
val lastSeenAt: Long?,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
)
@@ -98,6 +101,8 @@ expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition)
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String?
@@ -114,6 +119,8 @@ expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit
expect fun libraryLogPath(): String?
expect fun formatLibraryLastReadTime(millis: Long): String
internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
return image?.let(::binaryFor)
@@ -3,13 +3,17 @@ package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser
@@ -129,6 +133,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
val now = System.currentTimeMillis()
db.readingStates.upsert(
ReadingStateRecord(
id = "state-$clusterId",
@@ -138,9 +143,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(),
),
updatedAt = System.currentTimeMillis(),
updatedAt = now,
),
)
db.files.touchLastReadAt(fileId, now)
}
}
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.updateReadingStatus(fileId, status)
}
}
@@ -249,6 +261,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
actual fun formatLibraryLastReadTime(millis: Long): String =
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis))
private fun openLibraryDatabase(): H2LibraryDatabase {
val libraryDir = File(System.getProperty("user.home"), ".toread/library")
val dbDir = File(libraryDir, "db")
@@ -290,6 +305,8 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
sizeBytes = sizeBytes,
storageUri = storageUri,
lastSeenAt = lastSeenAt,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
)
private fun libraryLogFile(): File =
@@ -3,6 +3,7 @@ package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.browser.window
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus
import org.w3c.dom.events.Event
actual fun loadDefaultBookBytes(): ByteArray? = null
@@ -39,6 +40,8 @@ actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null
@@ -62,3 +65,29 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
}
actual fun libraryLogPath(): String? = null
actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L
val minute = (totalMinutes % 60).toString().padStart(2, '0')
val totalHours = totalMinutes / 60
val hour = (totalHours % 24).toString().padStart(2, '0')
val (yearValue, monthValue, dayValue) = civilDateFromEpochDay(totalHours / 24)
val year = yearValue.toString()
val month = monthValue.toString().padStart(2, '0')
val day = dayValue.toString().padStart(2, '0')
return "$year-$month-$day $hour:$minute"
}
private fun civilDateFromEpochDay(epochDay: Long): Triple<Int, Int, Int> {
val shiftedDay = epochDay + 719_468
val era = shiftedDay / 146_097
val dayOfEra = shiftedDay - era * 146_097
val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
var year = (yearOfEra + era * 400).toInt()
val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
val monthPart = (5 * dayOfYear + 2) / 153
val day = (dayOfYear - (153 * monthPart + 2) / 5 + 1).toInt()
val month = (monthPart + if (monthPart < 10) 3 else -9).toInt()
if (month <= 2) year += 1
return Triple(year, month, day)
}