improved UI
This commit is contained in:
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)
|
||||
}
|
||||
Reference in new issue
Block a user