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