restructured source, better library/reading

This commit is contained in:
Sergey Chernov 2026-05-17 02:14:05 +03:00
parent d8b39057d5
commit 3fd6606077
12 changed files with 5004 additions and 1463 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,85 @@
package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Format
internal sealed interface AppState {
data object LoadingLibrary : AppState
data class Library(
val items: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
) : AppState
data class Scan(
val items: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
) : AppState
data class Reader(
val fileId: String,
val book: Fb2Book,
val libraryItems: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
) : AppState
data class BookInfo(
val fileId: String,
val book: Fb2Book,
val libraryItems: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
) : AppState
data class Error(val message: String) : AppState
}
internal suspend fun loadStartupState(): AppState {
val library = loadLibraryState()
if (library !is AppState.Library) return library
loadPlatformOpenBookRequest()?.let { request ->
return runCatching {
AppState.Reader(
fileId = request.id,
book = Fb2Format.parse(request.bytes, request.displayName),
libraryItems = library.items,
scanPath = library.scanPath,
message = library.message,
)
}.getOrElse {
AppState.Library(library.items, library.scanPath, it.message ?: "Could not open ${request.displayName}.")
}
}
val activeFileId = loadActiveReadingFileId() ?: return library
val item = loadLibraryItem(activeFileId)
if (item == null) {
saveActiveReadingFileId(null)
return library
}
return runCatching {
val bytes = openLibraryBook(activeFileId) ?: error("Book file is not available.")
AppState.Reader(
fileId = activeFileId,
book = Fb2Format.parse(bytes, item.storageUri ?: item.title),
libraryItems = library.items,
scanPath = library.scanPath,
message = library.message,
)
}.getOrElse {
saveActiveReadingFileId(null)
AppState.Library(library.items, library.scanPath, it.message ?: "Could not reopen last book.")
}
}
internal suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState =
runCatching {
AppState.Library(
items = emptyList(),
scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(),
message = message,
)
}.getOrElse {
AppState.Error(it.message ?: "Could not open library.")
}

View File

@ -0,0 +1,167 @@
package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
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.Text
import androidx.compose.material3.TopAppBarDefaults
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.sergeych.toread.fb2.Fb2Book
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun BookInfoScreen(
fileId: String,
book: Fb2Book,
onBack: () -> Unit,
) {
val stats = remember(book) { BookStats.from(book) }
var extras by remember(fileId) { mutableStateOf<BookInfoExtras?>(null) }
LaunchedEffect(fileId) {
extras = loadBookInfoExtras(fileId)
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Book info") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it)
.background(readerBackground()),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
InfoSection("Title Info") {
CoverAndTitle(book)
DetailLine("Title", book.title)
DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" })
DetailLine("Language", book.language?.uppercase() ?: "Not specified")
DetailLine("Date", book.date ?: "Not specified")
DetailLine("Genres", book.genres.joinToString().ifBlank { "Not specified" })
DetailLine("Source", book.sourceLanguage?.uppercase() ?: "Not specified")
if (book.annotation.isNullOrBlank().not()) {
Text(book.annotation.orEmpty(), style = MaterialTheme.typography.bodyMedium, lineHeight = 20.sp)
}
}
}
item {
InfoSection("Statistics") {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
StatBlock("Words", stats.words.formatCompact())
StatBlock("Sections", stats.sections.toString())
StatBlock("Images", stats.images.toString())
}
}
}
item {
InfoSection("Last Reading Position") {
val position = extras?.lastReadingPosition
if (position == null) {
Text("No saved position", style = MaterialTheme.typography.bodyMedium)
} else {
DetailLine("List item", position.itemIndex.toString())
DetailLine("Scroll offset", "${position.scrollOffset}px")
}
}
}
item {
InfoSection("Bookmarks") {
val bookmarks = extras?.bookmarks.orEmpty()
if (extras == null) {
Text("Loading...", style = MaterialTheme.typography.bodyMedium)
} else if (bookmarks.isEmpty()) {
Text("No bookmarks", style = MaterialTheme.typography.bodyMedium)
} else {
bookmarks.forEach { bookmark ->
BookmarkInfoLine(bookmark)
}
}
}
}
item {
InfoSection("Notes") {
val notes = extras?.notes.orEmpty()
if (extras == null) {
Text("Loading...", style = MaterialTheme.typography.bodyMedium)
} else if (notes.isEmpty()) {
Text("No notes", style = MaterialTheme.typography.bodyMedium)
} else {
notes.forEach { note ->
NoteInfoLine(note)
}
}
}
}
}
}
}
@Composable
private fun InfoSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
content()
}
}
}
@Composable
private fun BookmarkInfoLine(bookmark: BookmarkInfo) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) {
Text(bookmark.title?.ifBlank { null } ?: "Bookmark", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
bookmark.selectedText?.ifBlank { null }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, maxLines = 3)
}
Text(bookmark.progress.progressLabel(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
}
}
@Composable
private fun NoteInfoLine(note: NoteInfo) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) {
Text(note.text, style = MaterialTheme.typography.bodyMedium, maxLines = 4)
Text(note.progress.progressLabel(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
}
}

View File

@ -0,0 +1,457 @@
package net.sergeych.toread
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
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.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun LibraryScreen(
state: AppState.Library,
onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) }
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return
loadingPage = true
if (reset) {
items = emptyList()
nextOffset = 0
endReached = false
coverCache.clear()
}
val offset = if (reset) 0 else nextOffset
try {
val page = loadLibraryItemsPage(LibraryPageSize, offset)
items = if (reset) page else items + page
nextOffset = offset + page.size
endReached = page.size < LibraryPageSize
} catch (t: Throwable) {
message = t.message ?: "Could not load library."
endReached = true
} finally {
loadingPage = false
}
}
fun refresh(nextMessage: String? = message) {
message = nextMessage
scope.launch { loadPage(reset = true) }
}
LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) loadPage(reset = true)
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Library") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
actions = {
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToScan) {
Icon(Icons.Filled.Add, contentDescription = "Scan folder")
}
},
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(it)
.background(readerBackground()),
) {
val wide = maxWidth >= 800.dp
if (items.isEmpty() && loadingPage) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (items.isEmpty()) {
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
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,
enabled = !busy,
onOpen = {
scope.launch {
busy = true
try {
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,
book = book,
libraryItems = items,
scanPath = state.scanPath,
message = message,
)
}.getOrElse {
AppState.Library(items, state.scanPath, it.message ?: "Could not open book.")
}
onStateChange(next)
} finally {
busy = false
}
}
},
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
try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
if (deleted) {
items = items.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId)
nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
}
} finally {
busy = false
}
}
},
)
}
if (!endReached) {
item(key = "load-more") {
LaunchedEffect(nextOffset, items.size) {
if (!loadingPage) loadPage()
}
Box(
modifier = Modifier.fillMaxWidth().padding(18.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
}
}
}
}
}
}
}
}
@Composable
private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
Box(modifier, contentAlignment = Alignment.Center) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("No books indexed", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Scan a folder containing FB2 or FB2.ZIP files.", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@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
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onOpen)
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
item.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
item.authors.joinToString().ifBlank { "Unknown author" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
)
Text(
item.libraryMetadataLine(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
)
}
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()
},
)
}
}
}
}
}
@Composable
private fun LibraryCover(
item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
modifier: Modifier = Modifier.width(54.dp).aspectRatio(0.68f),
) {
LaunchedEffect(item.fileId) {
if (!coverCache.containsKey(item.fileId)) {
coverCache[item.fileId] = loadLibraryItemCover(item.fileId)
}
}
val cover = coverCache[item.fileId]
val bitmap = remember(item.fileId, cover?.image) {
cover?.image?.let(::decodeImageBytes)
}
Box(
modifier = modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = item.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
item.title.firstOrNull()?.uppercase() ?: "",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
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
private fun Long.formatBytes(): String =
when {
this >= 1024L * 1024L -> "${(this / (1024.0 * 1024.0)).roundToInt()} MB"
this >= 1024L -> "${(this / 1024.0).roundToInt()} KB"
else -> "$this B"
}
private const val LibraryPageSize: Int = 50

View File

@ -0,0 +1,17 @@
package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
fun Fb2Binary.imageBytes(): ByteArray = Base64.Default.decode(base64)
const val DefaultBookFileName: String = "Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip"
expect fun loadDefaultBookBytes(): ByteArray?
expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap?
expect fun decodeImageBytes(bytes: ByteArray): ImageBitmap?

View File

@ -0,0 +1,453 @@
package net.sergeych.toread
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2ImageRef
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.text.HyphenationRegistry
@Composable
internal fun ContinuousBookReader(
book: Fb2Book,
stats: BookStats,
listState: LazyListState,
modifier: Modifier = Modifier,
) {
val hyphenation = remember { HyphenationRegistry() }
val contentPadding = if (isAndroidPlatform()) {
PaddingValues(start = 6.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
} else {
PaddingValues(horizontal = 8.dp, vertical = 6.dp)
}
LazyColumn(
state = listState,
modifier = modifier
.background(MaterialTheme.colorScheme.surface),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
CoverAndTitle(book)
MetadataCard(book)
StatsCard(stats)
}
}
item {
Spacer(Modifier.height(6.dp))
}
book.sections.forEachIndexed { index, section ->
sectionItems(
book = book,
section = section,
depth = 0,
keyPrefix = "section-$index",
hyphenation = hyphenation,
)
}
item { Spacer(Modifier.height(22.dp)) }
}
}
private fun LazyListScope.sectionItems(
book: Fb2Book,
section: Fb2Section,
depth: Int,
keyPrefix: String,
hyphenation: HyphenationRegistry,
) {
if( section.title.isNullOrBlank() ) {
item {
Spacer(Modifier.height(2.dp)
.background(MaterialTheme.colorScheme.secondaryFixedDim)
.fillMaxWidth().padding(vertical = 5.dp, horizontal = 4.dp)
)
}
}
else {
item(key = "$keyPrefix-title") {
Text(
section.title!!,
style = when (depth) {
0 -> MaterialTheme.typography.headlineMedium
1 -> MaterialTheme.typography.titleLarge
else -> MaterialTheme.typography.titleMedium
},
fontWeight = FontWeight.Bold,
lineHeight = if (depth == 0) 36.sp else 28.sp,
modifier = Modifier
.fillMaxWidth()
.padding(top = if (depth == 0) 22.dp else 14.dp, start = (depth * 12).dp, bottom = 4.dp),
)
}
}
items(section.readableBlocks()) { block ->
when (block) {
Fb2Block.EmptyLine -> Spacer(Modifier.height(16.dp))
is Fb2Block.Image -> BookImage(
book = book,
image = block.image,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
contentScale = ContentScale.Fit,
)
is Fb2Block.Paragraph -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
/* Justify adds extra padding to the end, which hardly can be removed */
textAlign = TextAlign.Justify,
// so we add 6.dp to make it look symmetric
modifier = Modifier.padding(start = (depth * 8).dp + 6.dp, end = 0.dp),
)
is Fb2Block.Subtitle -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
)
}
}
section.sections.forEachIndexed { index, child ->
sectionItems(
book = book,
section = child,
depth = depth + 1,
keyPrefix = "$keyPrefix-$index",
hyphenation = hyphenation,
)
}
}
@Composable
private fun DetailsPane(
book: Fb2Book,
stats: BookStats,
chapters: List<ChapterEntry>,
selectedChapter: Int,
onChapterSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
CoverAndTitle(book)
MetadataCard(book)
StatsCard(stats)
}
}
item {
Text(
"Sections",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 6.dp, start = 2.dp),
)
}
itemsIndexed(chapters) { index, chapter ->
Button(
onClick = { onChapterSelected(index) },
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
chapter.title,
maxLines = 2,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth().padding(start = (chapter.depth * 12).dp),
fontWeight = if (index == selectedChapter) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
@Composable
internal fun CoverAndTitle(book: Fb2Book) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
BookImage(
book = book,
image = book.coverImages.firstOrNull() ?: book.bodyImages.firstOrNull(),
modifier = Modifier.width(112.dp).aspectRatio(0.68f),
contentScale = ContentScale.Crop,
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.secondary,
)
Text(
listOfNotNull(book.date, book.language?.uppercase()).joinToString(" | "),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
internal fun MetadataCard(book: Fb2Book) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
DetailLine("Genres", book.genres.joinToString().ifBlank { "Not specified" })
DetailLine("Translator", book.translators.joinToString { it.displayName }.ifBlank { "Not specified" })
DetailLine("Source", book.sourceLanguage?.uppercase() ?: "Not specified")
if (book.annotation.isNullOrBlank().not()) {
Text(book.annotation.orEmpty(), style = MaterialTheme.typography.bodyMedium, lineHeight = 20.sp)
}
if (book.sequences.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
book.sequences.forEach { sequence ->
Chip("${sequence.name}${sequence.number?.let { " #$it" }.orEmpty()}")
}
}
}
}
}
}
@Composable
internal fun StatsCard(stats: BookStats) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
StatBlock("Words", stats.words.formatCompact())
StatBlock("Sections", stats.sections.toString())
StatBlock("Images", stats.images.toString())
}
}
}
@Composable
private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier = Modifier) {
val hyphenation = remember { HyphenationRegistry() }
val blocks = remember(section) { section?.readableBlocks().orEmpty() }
LazyColumn(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item { Spacer(Modifier.height(14.dp)) }
item {
Text(
section?.title ?: book.title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
lineHeight = 36.sp,
modifier = Modifier.padding(bottom = 8.dp),
)
}
items(blocks) { block ->
when (block) {
Fb2Block.EmptyLine -> Spacer(Modifier.height(16.dp))
is Fb2Block.Image -> BookImage(
book = book,
image = block.image,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
contentScale = ContentScale.Fit,
)
is Fb2Block.Paragraph -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
textAlign = TextAlign.Justify,
)
is Fb2Block.Subtitle -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
)
}
}
item { Spacer(Modifier.height(22.dp)) }
}
}
@Composable
private fun ReaderText(
text: Fb2Text,
language: String?,
hyphenation: HyphenationRegistry,
style: TextStyle,
textAlign: TextAlign,
modifier: Modifier = Modifier,
) {
Text(
text = text.toAnnotatedString(language, hyphenation),
style = style,
textAlign = textAlign,
modifier = modifier.fillMaxWidth(),
)
}
@Composable
private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp,
lineHeight = 27.sp,
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
private fun isAndroidPlatform(): Boolean =
getPlatform().name.startsWith("Android")
@Composable
private fun BookImage(
book: Fb2Book,
image: Fb2ImageRef?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
) {
val bitmap = remember(book, image) {
image?.let(book::binaryFor)?.let { decodeBookImage(it) }
}
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = image?.alt ?: book.title,
modifier = Modifier.fillMaxWidth(),
contentScale = contentScale,
)
} else {
Text("No image", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
}
}
}
private fun Fb2Text.toAnnotatedString(language: String?, hyphenation: HyphenationRegistry): AnnotatedString =
buildAnnotatedString {
spans.forEach { span ->
val spanStyle = SpanStyle(
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null,
fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null,
textDecoration = if (Fb2TextStyle.Strikethrough in span.styles) {
TextDecoration.LineThrough
} else {
null
},
baselineShift = when {
Fb2TextStyle.Superscript in span.styles -> BaselineShift.Superscript
Fb2TextStyle.Subscript in span.styles -> BaselineShift.Subscript
else -> null
},
)
withStyle(spanStyle) {
append(hyphenation.hyphenate(span.text, language))
}
}
}
private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
flatMapIndexed { index, section ->
val fallback = "Section ${index + 1}"
listOf(ChapterEntry(section.title?.ifBlank { null } ?: fallback, section, depth)) +
section.sections.flattenSections(depth + 1)
}
private fun Fb2Section.readableBlocks(): List<Fb2Block> =
blocks.ifEmpty {
images.map(Fb2Block::Image) + paragraphs.map { Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(it)))) }
}
private data class ChapterEntry(
val title: String,
val section: Fb2Section,
val depth: Int,
)
internal data class BookStats(
val words: Int,
val sections: Int,
val images: Int,
) {
companion object {
fun from(book: Fb2Book): BookStats {
val sections = book.sections.flattenSections()
val words = sections.sumOf { entry ->
entry.section.paragraphs.sumOf { paragraph -> paragraph.split(Regex("\\s+")).count { it.isNotBlank() } }
}
val bodyImages = sections.sumOf { it.section.images.size } + book.bodyImages.size
return BookStats(words = words, sections = sections.size, images = bodyImages + book.coverImages.size)
}
}
}

View File

@ -0,0 +1,168 @@
package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.Info
import androidx.compose.material.icons.filled.Palette
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.Surface
import androidx.compose.material3.Text
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.storage.BookReadingStatus
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
internal fun BookView(
fileId: String,
book: Fb2Book,
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onBack: () -> Unit,
) {
val stats = remember(book) { BookStats.from(book) }
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 ->
listState.scrollToItem(position.itemIndex, position.scrollOffset)
}
restored = true
}
LaunchedEffect(fileId, listState) {
snapshotFlow {
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
.filter { restored }
.distinctUntilChanged()
.debounce(750)
.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 = {
CompactReaderTopBar(
title = book.title,
onThemeToggle = onThemeToggle,
onBookInfo = {
scope.launch {
saveLibraryReadingPosition(
fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
)
onBookInfo()
}
},
onBack = {
scope.launch {
saveLibraryReadingPosition(
fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
)
saveActiveReadingFileId(null)
onBack()
}
},
)
},
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(it)
.background(readerBackground()),
) {
ContinuousBookReader(
book = book,
stats = stats,
modifier = Modifier.fillMaxSize(),
listState = listState,
)
}
}
}
@Composable
private fun CompactReaderTopBar(
title: String,
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onBack: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
}
Text(
title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = "Theme")
}
IconButton(onClick = onBookInfo) {
Icon(Icons.Filled.Info, contentDescription = "Properties")
}
IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
}
}
}
}

View File

@ -0,0 +1,171 @@
package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Scanner
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun ScanScreen(
state: AppState.Scan,
onStateChange: (AppState) -> Unit,
) {
val scope = rememberCoroutineScope()
var scanPath by remember(state.scanPath) { mutableStateOf(state.scanPath) }
var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var scanProgress by remember { mutableStateOf<LibraryScanProgress?>(null) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Scan") },
navigationIcon = {
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message)) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(it)
.background(readerBackground()),
) {
val wide = maxWidth >= 800.dp
Column(
modifier = Modifier
.fillMaxSize()
.padding(if (wide) 24.dp else 14.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = scanPath,
onValueChange = { scanPath = it },
label = { Text("Root folder") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = {
scope.launch {
busy = true
scanProgress = null
try {
message = "Scanning..."
val report = runCatching {
scanLibrarySubtree(scanPath) { progress ->
scope.launch { scanProgress = progress }
}
}
val nextMessage = report.fold(
onSuccess = {
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
},
onFailure = { it.message ?: "Scan failed." },
)
onStateChange(loadLibraryState(nextMessage, scanPath))
} finally {
busy = false
scanProgress = null
}
}
},
enabled = !busy && scanPath.isNotBlank(),
) {
Icon(Icons.Filled.Scanner, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Scan")
}
FilledTonalButton(
onClick = {
scope.launch {
chooseLibraryScanDirectory()?.let { scanPath = it }
}
},
enabled = !busy,
) {
Icon(Icons.Filled.FolderOpen, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Choose")
}
if (busy) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
}
}
if (busy) {
scanProgress?.let { progress ->
Text(
progress.toScanMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
message?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
libraryLogPath()?.let {
Text("Log: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
}
}
}
}
}
}
}
private fun LibraryScanProgress.toScanMessage(): String =
buildString {
append("Checking")
currentFile?.takeIf(String::isNotBlank)?.let { append(" $it") }
append(". Found $scannedFiles supported files")
append(", imported $importedFiles")
append(", skipped $skippedFiles")
append(", failed $failedFiles")
append(".")
}

View File

@ -0,0 +1,86 @@
package net.sergeych.toread
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
internal fun LoadingScreen(message: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
CircularProgressIndicator()
Text(message, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
internal fun ErrorScreen(message: String, onBack: () -> Unit) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Library error", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(message, style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = onBack) {
Text("Retry")
}
}
}
}
}
@Composable
internal fun DetailLine(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
internal fun StatBlock(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
}
}
@Composable
internal fun Chip(text: String) {
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
Text(text, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp))
}
}
@Composable
internal fun quietCardColors() = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
)
@Composable
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background)
internal fun Int.formatCompact(): String =
if (this >= 10_000) "${(this / 1000.0).roundToInt()}k" else toString()
internal fun Double?.progressLabel(): String =
this?.let { "Progress ${(it * 100).roundToInt()}%" } ?: "Progress not recorded"

View File

@ -0,0 +1,47 @@
package net.sergeych.toread
import androidx.compose.ui.graphics.Color
internal fun ThemeMode.next(): ThemeMode =
when (this) {
ThemeMode.SYSTEM -> ThemeMode.LIGHT
ThemeMode.LIGHT -> ThemeMode.DARK
ThemeMode.DARK -> ThemeMode.SYSTEM
}
internal val ThemeMode.displayName: String
get() = when (this) {
ThemeMode.LIGHT -> "Light"
ThemeMode.DARK -> "Dark"
ThemeMode.SYSTEM -> "System"
}
internal fun lightReaderColorScheme() = androidx.compose.material3.lightColorScheme(
primary = Color(0xFF425D56),
onPrimary = Color.White,
primaryContainer = Color(0xFFD8E8E2),
onPrimaryContainer = Color(0xFF17352E),
secondary = Color(0xFF735B2E),
secondaryContainer = Color(0xFFF1DDB6),
tertiary = Color(0xFF7E4A58),
background = Color(0xFFF7F2EA),
surface = Color(0xFFFFFBF5),
surfaceVariant = Color(0xFFE7DFD3),
onSurface = Color(0xFF24211D),
outline = Color(0xFF8C8174),
)
internal fun darkReaderColorScheme() = androidx.compose.material3.darkColorScheme(
primary = Color(0xFFAFCFC4),
onPrimary = Color(0xFF18352E),
primaryContainer = Color(0xFF2B4941),
onPrimaryContainer = Color(0xFFD8E8E2),
secondary = Color(0xFFE2C58F),
secondaryContainer = Color(0xFF59431B),
tertiary = Color(0xFFE9B8C3),
background = Color(0xFF171411),
surface = Color(0xFF211D19),
surfaceVariant = Color(0xFF4E463D),
onSurface = Color(0xFFECE0D4),
outline = Color(0xFFA99E91),
)

View File

@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@js-joda/core@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==

3345
kotlin-js-store/yarn.lock Normal file

File diff suppressed because it is too large Load Diff