restructured source, better library/reading
This commit is contained in:
parent
d8b39057d5
commit
3fd6606077
File diff suppressed because it is too large
Load Diff
@ -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.")
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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?
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(".")
|
||||||
|
}
|
||||||
@ -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"
|
||||||
@ -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),
|
||||||
|
)
|
||||||
8
kotlin-js-store/wasm/yarn.lock
Normal file
8
kotlin-js-store/wasm/yarn.lock
Normal 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
3345
kotlin-js-store/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user