Reading progress, persist library mode and improve scan feedback
This commit is contained in:
parent
e8b18ec472
commit
dbf63c351c
@ -473,6 +473,18 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withCo
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadReaderFullScreen(): Boolean = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(ReaderFullScreenFlag)?.toBooleanStrictOrNull() ?: false
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.setAppFlag(ReaderFullScreenFlag, fullScreen.toString())
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
|
||||
@ -485,6 +497,20 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
|
||||
}
|
||||
}
|
||||
|
||||
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(LibraryFilterFlag)
|
||||
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
|
||||
?: LibraryFilter.MyLibrary
|
||||
}
|
||||
}
|
||||
|
||||
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.setAppFlag(LibraryFilterFlag, filter.name)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
|
||||
@ -868,6 +894,8 @@ private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""")
|
||||
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
|
||||
private const val ThemeModeFlag = "theme_mode"
|
||||
private const val ReaderFontSettingsFlag = "reader_font_settings"
|
||||
private const val ReaderFullScreenFlag = "reader_full_screen"
|
||||
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
|
||||
private const val LibraryFilterFlag = "library_filter"
|
||||
private const val AppLocaleTagFlag = "app_locale_tag"
|
||||
private const val DownloadsWasScannedFlag = "downloads_was_scanned"
|
||||
|
||||
@ -27,13 +27,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import net.sergeych.toread.fb2.Fb2Format
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DefaultToastDurationMillis = 1_600L
|
||||
private const val DeleteUndoDurationMillis = 5_000L
|
||||
private const val ScanResultToastDurationMillis = 5_000L
|
||||
private const val BackgroundLibraryPageSize = 50
|
||||
|
||||
private data class AppToastData(
|
||||
@ -220,7 +220,12 @@ private fun BookReaderApp(
|
||||
is AppState.BookInfo -> current.scanPath
|
||||
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
|
||||
}
|
||||
state = AppState.Library(emptyList(), scanPath, requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary)
|
||||
state = AppState.Library(
|
||||
emptyList(),
|
||||
scanPath,
|
||||
requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary,
|
||||
loadLibraryFilter(),
|
||||
)
|
||||
continue
|
||||
}
|
||||
val request = requestResult.getOrNull() ?: continue
|
||||
@ -232,7 +237,7 @@ private fun BookReaderApp(
|
||||
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
|
||||
}
|
||||
val nextState = runCatching {
|
||||
val book = Fb2Format.parse(request.bytes, request.displayName)
|
||||
val book = parseBookInBackground(request.bytes, request.displayName)
|
||||
saveActiveReadingFileId(request.id)
|
||||
AppState.Reader(
|
||||
fileId = request.id,
|
||||
@ -241,11 +246,11 @@ private fun BookReaderApp(
|
||||
scanPath = scanPath,
|
||||
)
|
||||
}.getOrElse {
|
||||
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName))
|
||||
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), loadLibraryFilter())
|
||||
}
|
||||
libraryBackState = when (val current = state) {
|
||||
is AppState.Library -> current
|
||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message, current.selectedFilter)
|
||||
is AppState.Reader, is AppState.BookInfo -> libraryBackState
|
||||
is AppState.Error, AppState.LoadingStartup -> null
|
||||
}
|
||||
@ -362,7 +367,7 @@ private fun BookReaderApp(
|
||||
downloadsRescanRequestGeneration += 1
|
||||
backState
|
||||
}
|
||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message, current.selectedFilter)
|
||||
is AppState.Error -> AppState.LoadingStartup
|
||||
is AppState.Library, AppState.LoadingStartup -> current
|
||||
}
|
||||
@ -380,7 +385,23 @@ private fun BookReaderApp(
|
||||
onBack = ::navigateBack,
|
||||
)
|
||||
|
||||
fun startScan(path: String) {
|
||||
fun setLibraryFilter(filter: LibraryFilter) {
|
||||
state = when (val current = state) {
|
||||
is AppState.Library -> current.copy(selectedFilter = filter)
|
||||
is AppState.Scan -> current.copy(selectedFilter = filter)
|
||||
else -> current
|
||||
}
|
||||
libraryBackState = libraryBackState?.copy(selectedFilter = filter)
|
||||
}
|
||||
|
||||
fun saveAndSetLibraryFilter(filter: LibraryFilter) {
|
||||
setLibraryFilter(filter)
|
||||
scope.launch {
|
||||
saveLibraryFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
fun startScan(path: String, userInitiated: Boolean) {
|
||||
if (scanJob?.isActive == true) return
|
||||
activeScan = LibraryScanProgress(0, 0, 0, 0)
|
||||
scanJob = scope.launch {
|
||||
@ -400,19 +421,34 @@ private fun BookReaderApp(
|
||||
},
|
||||
onFailure = { it.message ?: strings.scanFailed },
|
||||
)
|
||||
val importedFiles = report.getOrNull()?.importedFiles
|
||||
when {
|
||||
importedFiles == null -> Unit
|
||||
importedFiles > 0 -> {
|
||||
onShowToast(
|
||||
strings.newBooksAdded(importedFiles),
|
||||
strings.show,
|
||||
{ saveAndSetLibraryFilter(LibraryFilter.RecentlyAdded) },
|
||||
ScanResultToastDurationMillis,
|
||||
)
|
||||
}
|
||||
userInitiated -> {
|
||||
onShowToast(strings.noNewBooksFound, null, null, DefaultToastDurationMillis)
|
||||
}
|
||||
}
|
||||
scanJob = null
|
||||
activeScan = null
|
||||
state = when (val current = state) {
|
||||
is AppState.Library -> if (report.getOrNull()?.hasLibraryChanges() == true) {
|
||||
loadLibraryState(message, path)
|
||||
} else {
|
||||
current.copy(scanPath = path, message = message)
|
||||
}
|
||||
is AppState.Scan -> if (report.getOrNull()?.hasLibraryChanges() == true) {
|
||||
loadLibraryState(message, path)
|
||||
} else {
|
||||
AppState.Library(current.items, path, message)
|
||||
}
|
||||
is AppState.Library -> current.copy(
|
||||
scanPath = path,
|
||||
message = message,
|
||||
)
|
||||
is AppState.Scan -> AppState.Library(
|
||||
current.items,
|
||||
path,
|
||||
message,
|
||||
selectedFilter = current.selectedFilter,
|
||||
)
|
||||
AppState.LoadingStartup -> loadLibraryState(message, path)
|
||||
is AppState.Reader -> current.copy(message = message)
|
||||
is AppState.BookInfo -> current.copy(message = message)
|
||||
@ -426,7 +462,7 @@ private fun BookReaderApp(
|
||||
if (!loadScanDownloadsAutomatically() || !loadDownloadsWasScanned()) return@LaunchedEffect
|
||||
val path = downloadsScanPath() ?: return@LaunchedEffect
|
||||
state = state.withMessage(strings.scanningDownloads)
|
||||
startScan(path)
|
||||
startScan(path, userInitiated = false)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@ -448,9 +484,15 @@ private fun BookReaderApp(
|
||||
}
|
||||
state = next
|
||||
},
|
||||
onLibraryFilterChange = ::setLibraryFilter,
|
||||
onNavigateToScan = {
|
||||
libraryBackState = null
|
||||
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message)
|
||||
state = AppState.Scan(
|
||||
libraryState.items,
|
||||
libraryState.scanPath,
|
||||
libraryState.message,
|
||||
libraryState.selectedFilter,
|
||||
)
|
||||
},
|
||||
onDeleteRequested = ::requestDelete,
|
||||
)
|
||||
@ -464,8 +506,8 @@ private fun BookReaderApp(
|
||||
activeScan = activeScan,
|
||||
onStateChange = { state = it },
|
||||
onStartScan = { path ->
|
||||
startScan(path)
|
||||
state = AppState.Library(current.items, path, strings.scanning)
|
||||
startScan(path, userInitiated = true)
|
||||
state = AppState.Library(current.items, path, strings.scanning, current.selectedFilter)
|
||||
},
|
||||
)
|
||||
is AppState.Reader -> BookView(
|
||||
@ -516,9 +558,6 @@ internal data class ViewedBookImage(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
|
||||
importedFiles > 0
|
||||
|
||||
private fun AppState.withMessage(message: String): AppState =
|
||||
when (this) {
|
||||
is AppState.Library -> copy(message = message)
|
||||
|
||||
@ -2,6 +2,8 @@ package net.sergeych.toread
|
||||
|
||||
import net.sergeych.toread.fb2.Fb2Book
|
||||
import net.sergeych.toread.fb2.Fb2Format
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal sealed interface AppState {
|
||||
data object LoadingStartup : AppState
|
||||
@ -9,12 +11,14 @@ internal sealed interface AppState {
|
||||
val items: List<LibraryItem>,
|
||||
val scanPath: String,
|
||||
val message: String? = null,
|
||||
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
|
||||
) : AppState
|
||||
|
||||
data class Scan(
|
||||
val items: List<LibraryItem>,
|
||||
val scanPath: String,
|
||||
val message: String? = null,
|
||||
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
|
||||
) : AppState
|
||||
|
||||
data class Reader(
|
||||
@ -42,12 +46,13 @@ internal suspend fun loadStartupState(): AppState {
|
||||
} catch (t: Throwable) {
|
||||
return AppState.Error(t.message ?: strings.couldNotOpenLibrary)
|
||||
}
|
||||
val libraryFilter = loadLibraryFilter()
|
||||
val platformRequest = runCatching { loadPlatformOpenBookRequest() }.getOrElse {
|
||||
return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary)
|
||||
return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary, libraryFilter)
|
||||
}
|
||||
platformRequest?.let { request ->
|
||||
return runCatching {
|
||||
val book = Fb2Format.parse(request.bytes, request.displayName)
|
||||
val book = parseBookInBackground(request.bytes, request.displayName)
|
||||
saveActiveReadingFileId(request.id)
|
||||
AppState.Reader(
|
||||
fileId = request.id,
|
||||
@ -56,36 +61,46 @@ internal suspend fun loadStartupState(): AppState {
|
||||
scanPath = scanPath,
|
||||
)
|
||||
}.getOrElse {
|
||||
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName))
|
||||
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), libraryFilter)
|
||||
}
|
||||
}
|
||||
val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath)
|
||||
val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath, selectedFilter = libraryFilter)
|
||||
val item = loadLibraryItem(activeFileId)
|
||||
if (item == null) {
|
||||
saveActiveReadingFileId(null)
|
||||
return AppState.Library(emptyList(), scanPath)
|
||||
return AppState.Library(emptyList(), scanPath, selectedFilter = libraryFilter)
|
||||
}
|
||||
return runCatching {
|
||||
val bytes = openLibraryBook(activeFileId) ?: error(strings.bookFileNotAvailable)
|
||||
AppState.Reader(
|
||||
fileId = activeFileId,
|
||||
book = Fb2Format.parse(bytes, item.storageUri ?: item.title),
|
||||
book = parseBookInBackground(bytes, item.storageUri ?: item.title),
|
||||
libraryItems = emptyList(),
|
||||
scanPath = scanPath,
|
||||
)
|
||||
}.getOrElse {
|
||||
saveActiveReadingFileId(null)
|
||||
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotReopenLastBook())
|
||||
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotReopenLastBook(), libraryFilter)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState =
|
||||
internal suspend fun loadLibraryState(
|
||||
message: String? = null,
|
||||
scanPath: String? = null,
|
||||
selectedFilter: LibraryFilter? = null,
|
||||
): AppState =
|
||||
runCatching {
|
||||
AppState.Library(
|
||||
items = emptyList(),
|
||||
scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(),
|
||||
message = message,
|
||||
selectedFilter = selectedFilter ?: loadLibraryFilter(),
|
||||
)
|
||||
}.getOrElse {
|
||||
AppState.Error(it.message ?: strings.couldNotOpenLibrary)
|
||||
}
|
||||
|
||||
internal suspend fun parseBookInBackground(input: ByteArray, fileName: String? = null): Fb2Book =
|
||||
withContext(Dispatchers.Default) {
|
||||
Fb2Format.parse(input, fileName)
|
||||
}
|
||||
|
||||
@ -159,10 +159,18 @@ expect suspend fun loadReaderFontSettings(): ReaderFontSettings
|
||||
|
||||
expect suspend fun saveReaderFontSettings(settings: ReaderFontSettings)
|
||||
|
||||
expect suspend fun loadReaderFullScreen(): Boolean
|
||||
|
||||
expect suspend fun saveReaderFullScreen(fullScreen: Boolean)
|
||||
|
||||
expect suspend fun loadScanDownloadsAutomatically(): Boolean
|
||||
|
||||
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
|
||||
|
||||
internal expect suspend fun loadLibraryFilter(): LibraryFilter
|
||||
|
||||
internal expect suspend fun saveLibraryFilter(filter: LibraryFilter)
|
||||
|
||||
expect suspend fun loadAppLocaleTag(): String?
|
||||
|
||||
expect suspend fun saveAppLocaleTag(localeTag: String?)
|
||||
|
||||
@ -83,7 +83,6 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.delay
|
||||
@ -97,6 +96,7 @@ internal fun LibraryScreen(
|
||||
itemRefreshRequest: LibraryItemRefreshRequest?,
|
||||
hiddenFileIds: Set<String>,
|
||||
onStateChange: (AppState) -> Unit,
|
||||
onLibraryFilterChange: (LibraryFilter) -> Unit,
|
||||
onNavigateToScan: () -> Unit,
|
||||
onDeleteRequested: (
|
||||
request: LibraryDeleteRequest,
|
||||
@ -107,6 +107,7 @@ internal fun LibraryScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var openingBook by remember { mutableStateOf(false) }
|
||||
var message by remember(state.message) { mutableStateOf(state.message) }
|
||||
var items by remember(state.items) { mutableStateOf(state.items) }
|
||||
var loadingLibrary by remember(state.items) { mutableStateOf(false) }
|
||||
@ -119,8 +120,7 @@ internal fun LibraryScreen(
|
||||
var searchFocused by remember { mutableStateOf(false) }
|
||||
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var selectedFilter by remember(state.scanPath) { mutableStateOf(LibraryFilter.ReadingNow) }
|
||||
var filterChosenByUser by remember(state.scanPath) { mutableStateOf(false) }
|
||||
var selectedFilter by remember(state.scanPath, state.selectedFilter) { mutableStateOf(state.selectedFilter) }
|
||||
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
||||
val searchActive = searchText.isNotBlank()
|
||||
val libraryItems = items.filterNot { it.fileId in hiddenFileIds }
|
||||
@ -302,13 +302,6 @@ internal fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchActive, loadingLibrary, libraryItems, recentlyAdded) {
|
||||
val libraryDataLoaded = libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty()
|
||||
if (!filterChosenByUser && !searchActive && !loadingLibrary && libraryDataLoaded) {
|
||||
selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
autoScanDownloads = loadScanDownloadsAutomatically()
|
||||
}
|
||||
@ -328,7 +321,8 @@ internal fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@ -341,8 +335,11 @@ internal fun LibraryScreen(
|
||||
LibraryFilterSelect(
|
||||
selected = selectedFilter,
|
||||
onSelected = {
|
||||
filterChosenByUser = true
|
||||
selectedFilter = it
|
||||
onLibraryFilterChange(it)
|
||||
scope.launch {
|
||||
saveLibraryFilter(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -456,31 +453,31 @@ internal fun LibraryScreen(
|
||||
Icon(Icons.Filled.Add, contentDescription = strings.scanFolder)
|
||||
}
|
||||
},
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when {
|
||||
event.key == Key.Escape && searchText.isNotBlank() -> {
|
||||
clearSearch()
|
||||
true
|
||||
}
|
||||
event.key == Key.Escape && searchFocused -> {
|
||||
closeSearch()
|
||||
true
|
||||
}
|
||||
event.key.isEnterKey() && searchFocused && searchText.isBlank() -> {
|
||||
closeSearch()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.background(readerBackground()),
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when {
|
||||
event.key == Key.Escape && searchText.isNotBlank() -> {
|
||||
clearSearch()
|
||||
true
|
||||
}
|
||||
event.key == Key.Escape && searchFocused -> {
|
||||
closeSearch()
|
||||
true
|
||||
}
|
||||
event.key.isEnterKey() && searchFocused && searchText.isBlank() -> {
|
||||
closeSearch()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.background(readerBackground()),
|
||||
) {
|
||||
val wide = maxWidth >= 800.dp
|
||||
if (visibleItems.isEmpty() && (loadingLibrary || searching)) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@ -505,10 +502,11 @@ internal fun LibraryScreen(
|
||||
onOpen = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
openingBook = true
|
||||
try {
|
||||
val next = runCatching {
|
||||
val bytes = openLibraryBook(item.fileId) ?: error(strings.bookFileNotAvailable)
|
||||
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
|
||||
val book = parseBookInBackground(bytes, item.storageUri ?: item.title)
|
||||
var readerLibraryItems = visibleItems
|
||||
refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem ->
|
||||
items = items.replaceLibraryItem(updatedItem)
|
||||
@ -534,10 +532,16 @@ internal fun LibraryScreen(
|
||||
message = message,
|
||||
)
|
||||
}.getOrElse {
|
||||
AppState.Library(visibleItems, state.scanPath, it.message ?: strings.couldNotOpenBook)
|
||||
AppState.Library(
|
||||
visibleItems,
|
||||
state.scanPath,
|
||||
it.message ?: strings.couldNotOpenBook,
|
||||
state.selectedFilter,
|
||||
)
|
||||
}
|
||||
onStateChange(next)
|
||||
} finally {
|
||||
openingBook = false
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
@ -674,6 +678,10 @@ internal fun LibraryScreen(
|
||||
.padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (openingBook) {
|
||||
LoadingOverlay(strings.loadingOpeningBook)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1061,7 +1069,7 @@ private data class LibraryItemActions(
|
||||
val onDelete: () -> Unit,
|
||||
)
|
||||
|
||||
private enum class LibraryFilter {
|
||||
internal enum class LibraryFilter {
|
||||
ReadingNow,
|
||||
RecentlyAdded,
|
||||
MyLibrary,
|
||||
@ -1178,16 +1186,6 @@ private val LibraryFilter.label: String
|
||||
LibraryFilter.NotInterested -> strings.notInterested
|
||||
}
|
||||
|
||||
private fun defaultLibraryFilter(
|
||||
libraryItems: List<LibraryItem>,
|
||||
recentlyAddedItems: List<LibraryItem>,
|
||||
): LibraryFilter =
|
||||
when {
|
||||
libraryItems.any { it.readingStatus == BookReadingStatus.READING } -> LibraryFilter.ReadingNow
|
||||
recentlyAddedItems.any { it.readingStatus == BookReadingStatus.NEW } -> LibraryFilter.RecentlyAdded
|
||||
else -> LibraryFilter.MyLibrary
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryCover(
|
||||
item: LibraryItem,
|
||||
|
||||
@ -139,6 +139,7 @@ internal open class AppStrings {
|
||||
open val readerMenu = "Book reader menu"
|
||||
open val info = "Info..."
|
||||
open val readerSettings = "Settings"
|
||||
open val fullScreen = "Full screen"
|
||||
open val readerFontSizeIncrease = "Increase font size"
|
||||
open val readerFontSizeDecrease = "Decrease font size"
|
||||
open val readerLineHeightIncrease = "Increase line spacing"
|
||||
@ -187,6 +188,10 @@ internal open class AppStrings {
|
||||
open fun couldNotReopenLastBook(): String = "Could not reopen last book."
|
||||
open fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String =
|
||||
"Scanned $scanned, imported $imported, skipped $skipped, failed $failed."
|
||||
open val noNewBooksFound = "Nothing new has been found."
|
||||
open val show = "Show"
|
||||
open fun newBooksAdded(count: Int): String =
|
||||
if (count == 1) "1 new book added." else "$count new books added."
|
||||
open fun rescanReport(scanned: Int, updated: Int, failed: Int): String =
|
||||
"Rescanned $scanned, updated $updated, failed $failed."
|
||||
open fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String =
|
||||
@ -345,6 +350,7 @@ internal object RussianStrings : AppStrings() {
|
||||
override val readerMenu = "Меню чтения"
|
||||
override val info = "Информация..."
|
||||
override val readerSettings = "Настройки"
|
||||
override val fullScreen = "На весь экран"
|
||||
override val readerFontSizeIncrease = "Увеличить шрифт"
|
||||
override val readerFontSizeDecrease = "Уменьшить шрифт"
|
||||
override val readerLineHeightIncrease = "Увеличить межстрочный интервал"
|
||||
@ -393,6 +399,14 @@ internal object RussianStrings : AppStrings() {
|
||||
override fun couldNotReopenLastBook(): String = "Не удалось открыть последнюю книгу."
|
||||
override fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String =
|
||||
"Проверено: $scanned, импортировано: $imported, пропущено: $skipped, ошибок: $failed."
|
||||
override val noNewBooksFound = "Ничего нового не найдено."
|
||||
override val show = "Показать"
|
||||
override fun newBooksAdded(count: Int): String =
|
||||
when {
|
||||
count % 10 == 1 && count % 100 != 11 -> "$count новая книга добавлена."
|
||||
count % 10 in 2..4 && count % 100 !in 12..14 -> "$count новые книги добавлены."
|
||||
else -> "$count новых книг добавлено."
|
||||
}
|
||||
override fun rescanReport(scanned: Int, updated: Int, failed: Int): String =
|
||||
"Пересканировано: $scanned, обновлено: $updated, ошибок: $failed."
|
||||
override fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String =
|
||||
|
||||
@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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
|
||||
@ -52,6 +53,7 @@ import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
@ -98,12 +100,19 @@ import net.sergeych.toread.fb2.Fb2TextSpan
|
||||
import net.sergeych.toread.fb2.Fb2TextStyle
|
||||
import net.sergeych.toread.text.HyphenationRegistry
|
||||
import net.sergeych.toread.text.SoftHyphen
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private data class DecodedBookImage(
|
||||
val bitmap: ImageBitmap,
|
||||
val bytes: ByteArray,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ContinuousBookReader(
|
||||
book: Fb2Book,
|
||||
@ -1035,9 +1044,18 @@ private fun BookImage(
|
||||
val binary = remember(book, image) {
|
||||
image?.let(book::binaryFor)
|
||||
}
|
||||
val bitmap = remember(binary) {
|
||||
binary?.let { decodeBookImage(it) }
|
||||
var decodedImage by remember(binary) { mutableStateOf<DecodedBookImage?>(null) }
|
||||
LaunchedEffect(binary) {
|
||||
decodedImage = withContext(Dispatchers.Default) {
|
||||
binary?.let {
|
||||
val bytes = it.imageBytes()
|
||||
val bitmap = decodeImageBytes(bytes) ?: return@let null
|
||||
DecodedBookImage(bitmap, bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
val currentDecodedImage = decodedImage
|
||||
val bitmap = currentDecodedImage?.bitmap
|
||||
val imageTitle = image?.alt?.ifBlank { null } ?: book.title
|
||||
val imageBackgroundColor = readerImageBackgroundColor()
|
||||
val imageShape = RoundedCornerShape(8.dp)
|
||||
@ -1048,12 +1066,12 @@ private fun BookImage(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.then(
|
||||
if (bitmap != null && binary != null) {
|
||||
if (currentDecodedImage != null && binary != null) {
|
||||
Modifier.clickable {
|
||||
onOpen(
|
||||
ViewedBookImage(
|
||||
bitmap = bitmap,
|
||||
bytes = binary.imageBytes(),
|
||||
bitmap = currentDecodedImage.bitmap,
|
||||
bytes = currentDecodedImage.bytes,
|
||||
mimeType = binary.contentType,
|
||||
title = imageTitle,
|
||||
),
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
package net.sergeych.toread
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@ -11,6 +18,7 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -45,6 +53,7 @@ import androidx.compose.material.icons.filled.UnfoldLess
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@ -64,6 +73,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -74,20 +84,32 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.sergeych.toread.fb2.Fb2Book
|
||||
import net.sergeych.toread.storage.BookReadingStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private data class ReaderBookPreparation(
|
||||
val stats: BookStats,
|
||||
val contentPlan: ReaderContentPlan,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
|
||||
internal fun BookView(
|
||||
@ -105,17 +127,17 @@ internal fun BookView(
|
||||
) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val stats = remember(book) { BookStats.from(book) }
|
||||
val contentPlan = remember(book) { buildReaderContentPlan(book) }
|
||||
val listState = remember(fileId) { LazyListState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var preparation by remember(book) { mutableStateOf<ReaderBookPreparation?>(null) }
|
||||
var restored by remember(fileId) { mutableStateOf(false) }
|
||||
var markedRead by remember(fileId) { mutableStateOf(false) }
|
||||
var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) }
|
||||
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var fullScreenReader by remember { mutableStateOf(false) }
|
||||
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
|
||||
var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) }
|
||||
@ -125,9 +147,31 @@ internal fun BookView(
|
||||
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
|
||||
val readAloudState by ReadAloudPlatform.state.collectAsState()
|
||||
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
|
||||
|
||||
LaunchedEffect(book) {
|
||||
preparation = withContext(Dispatchers.Default) {
|
||||
ReaderBookPreparation(
|
||||
stats = BookStats.from(book),
|
||||
contentPlan = buildReaderContentPlan(book),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val preparedBook = preparation
|
||||
if (preparedBook == null) {
|
||||
LoadingScreen(strings.loadingOpeningBook)
|
||||
return
|
||||
}
|
||||
|
||||
val stats = preparedBook.stats
|
||||
val contentPlan = preparedBook.contentPlan
|
||||
val showShareAction = canShareLibraryBookFile()
|
||||
val showViewFileAction = canViewLibraryBookFile()
|
||||
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
|
||||
val fullScreenProgressTitle = remember(book) { book.fullScreenProgressTitle() }
|
||||
val readingProgress by remember(listState) {
|
||||
derivedStateOf { listState.readerProgress() }
|
||||
}
|
||||
val activeReadAloudSentence = readAloudState.sentenceIndex
|
||||
?.let { index -> contentPlan.sentences.getOrNull(index) }
|
||||
?.takeIf { readAloudState.active }
|
||||
@ -157,6 +201,14 @@ internal fun BookView(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateReaderFullScreen(fullScreen: Boolean) {
|
||||
if (fullScreen == fullScreenReader) return
|
||||
fullScreenReader = fullScreen
|
||||
scope.launch {
|
||||
saveReaderFullScreen(fullScreen)
|
||||
}
|
||||
}
|
||||
|
||||
fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition =
|
||||
ReadingPosition(
|
||||
listState.firstVisibleItemIndex,
|
||||
@ -225,6 +277,7 @@ internal fun BookView(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
readerFontSettings = loadReaderFontSettings()
|
||||
fullScreenReader = loadReaderFullScreen()
|
||||
}
|
||||
|
||||
DisposableEffect(fileId) {
|
||||
@ -294,94 +347,102 @@ internal fun BookView(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
CompactReaderTopBar(
|
||||
title = book.title,
|
||||
onThemeToggle = onThemeToggle,
|
||||
onBookInfo = {
|
||||
scope.launch {
|
||||
saveLibraryReadingPosition(
|
||||
fileId,
|
||||
currentReadingPosition(),
|
||||
)
|
||||
onBookInfo()
|
||||
}
|
||||
},
|
||||
onTableOfContents = ::openTableOfContents,
|
||||
onMarkAsRead = {
|
||||
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
|
||||
},
|
||||
onMarkToRead = {
|
||||
setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead())
|
||||
},
|
||||
onNotInterested = {
|
||||
setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested())
|
||||
},
|
||||
onClearMarks = {
|
||||
setReadingStatus(BookReadingStatus.NEW, strings.removedMarks())
|
||||
},
|
||||
readingStatus = libraryItem?.readingStatus,
|
||||
favorite = libraryItem?.favorite == true,
|
||||
onFavoriteChange = { favorite ->
|
||||
scope.launch {
|
||||
if (markLibraryFavorite(fileId, favorite)) {
|
||||
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
|
||||
onBookChanged(fileId)
|
||||
showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites())
|
||||
} else {
|
||||
showMessage(strings.couldNotUpdateBook)
|
||||
AnimatedVisibility(
|
||||
visible = !fullScreenReader,
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||
) {
|
||||
CompactReaderTopBar(
|
||||
title = book.title,
|
||||
onThemeToggle = onThemeToggle,
|
||||
onBookInfo = {
|
||||
scope.launch {
|
||||
saveLibraryReadingPosition(
|
||||
fileId,
|
||||
currentReadingPosition(),
|
||||
)
|
||||
onBookInfo()
|
||||
}
|
||||
}
|
||||
},
|
||||
showShareAction = showShareAction,
|
||||
onShare = {
|
||||
scope.launch {
|
||||
showMessage(shareLibraryBook(fileId))
|
||||
}
|
||||
},
|
||||
showViewFileAction = showViewFileAction,
|
||||
onViewFile = {
|
||||
scope.launch {
|
||||
showMessage(viewLibraryBook(fileId))
|
||||
}
|
||||
},
|
||||
onReaderSettings = {
|
||||
readerSettingsPanelVisible = true
|
||||
},
|
||||
showReadAloudAction = showReadAloudAction,
|
||||
onReadAloud = {
|
||||
val position = currentReadingPosition()
|
||||
val startIndex = contentPlan.resumeSentenceIndex(position)
|
||||
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
||||
readAloudPanelVisible = true
|
||||
ReadAloudPlatform.play()
|
||||
},
|
||||
onDelete = {
|
||||
onDeleteRequested(
|
||||
LibraryDeleteRequest(fileId, book.title),
|
||||
{
|
||||
scope.launch {
|
||||
saveActiveReadingFileId(null)
|
||||
},
|
||||
onTableOfContents = ::openTableOfContents,
|
||||
onMarkAsRead = {
|
||||
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
|
||||
},
|
||||
onMarkToRead = {
|
||||
setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead())
|
||||
},
|
||||
onNotInterested = {
|
||||
setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested())
|
||||
},
|
||||
onClearMarks = {
|
||||
setReadingStatus(BookReadingStatus.NEW, strings.removedMarks())
|
||||
},
|
||||
readingStatus = libraryItem?.readingStatus,
|
||||
favorite = libraryItem?.favorite == true,
|
||||
onFavoriteChange = { favorite ->
|
||||
scope.launch {
|
||||
if (markLibraryFavorite(fileId, favorite)) {
|
||||
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
|
||||
onBookChanged(fileId)
|
||||
showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites())
|
||||
} else {
|
||||
showMessage(strings.couldNotUpdateBook)
|
||||
}
|
||||
onDeleted(null)
|
||||
},
|
||||
{
|
||||
scope.launch {
|
||||
saveActiveReadingFileId(fileId)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onBack = {
|
||||
scope.launch {
|
||||
saveLibraryReadingPosition(
|
||||
fileId,
|
||||
currentReadingPosition(),
|
||||
}
|
||||
},
|
||||
fullScreen = fullScreenReader,
|
||||
onFullScreenChange = ::updateReaderFullScreen,
|
||||
showShareAction = showShareAction,
|
||||
onShare = {
|
||||
scope.launch {
|
||||
showMessage(shareLibraryBook(fileId))
|
||||
}
|
||||
},
|
||||
showViewFileAction = showViewFileAction,
|
||||
onViewFile = {
|
||||
scope.launch {
|
||||
showMessage(viewLibraryBook(fileId))
|
||||
}
|
||||
},
|
||||
onReaderSettings = {
|
||||
readerSettingsPanelVisible = true
|
||||
},
|
||||
showReadAloudAction = showReadAloudAction,
|
||||
onReadAloud = {
|
||||
val position = currentReadingPosition()
|
||||
val startIndex = contentPlan.resumeSentenceIndex(position)
|
||||
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
||||
readAloudPanelVisible = true
|
||||
ReadAloudPlatform.play()
|
||||
},
|
||||
onDelete = {
|
||||
onDeleteRequested(
|
||||
LibraryDeleteRequest(fileId, book.title),
|
||||
{
|
||||
scope.launch {
|
||||
saveActiveReadingFileId(null)
|
||||
}
|
||||
onDeleted(null)
|
||||
},
|
||||
{
|
||||
scope.launch {
|
||||
saveActiveReadingFileId(fileId)
|
||||
}
|
||||
},
|
||||
)
|
||||
saveActiveReadingFileId(null)
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onBack = {
|
||||
scope.launch {
|
||||
saveLibraryReadingPosition(
|
||||
fileId,
|
||||
currentReadingPosition(),
|
||||
)
|
||||
saveActiveReadingFileId(null)
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
@ -452,6 +513,12 @@ internal fun BookView(
|
||||
},
|
||||
)
|
||||
}
|
||||
ReaderProgressPane(
|
||||
progress = readingProgress,
|
||||
fullScreen = fullScreenReader,
|
||||
fullScreenTitle = fullScreenProgressTitle,
|
||||
onFullScreenToggle = { updateReaderFullScreen(!fullScreenReader) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -505,6 +572,151 @@ internal fun BookView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderProgressPane(
|
||||
progress: Float,
|
||||
fullScreen: Boolean,
|
||||
fullScreenTitle: String,
|
||||
onFullScreenToggle: () -> Unit,
|
||||
) {
|
||||
val percent = (progress.coerceIn(0f, 1f) * 100f).roundToInt()
|
||||
val trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)
|
||||
val readColor = MaterialTheme.colorScheme.primary
|
||||
val fullScreenFillColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
|
||||
val contentDescription = strings.progressLabel(progress.toDouble())
|
||||
val height by animateDpAsState(if (fullScreen) 36.dp else 24.dp)
|
||||
val progressRowHeight by animateDpAsState(24.dp)
|
||||
val canvasHeight by animateDpAsState(if (fullScreen) 12.dp else 8.dp)
|
||||
val trackStroke by animateDpAsState(1.dp)
|
||||
val readStroke by animateDpAsState(3.dp)
|
||||
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onFullScreenToggle),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.then(
|
||||
if (fullScreen) {
|
||||
Modifier.drawBehind {
|
||||
drawRect(
|
||||
color = fullScreenFillColor,
|
||||
size = Size(size.width * progress.coerceIn(0f, 1f), size.height),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.semantics { this.contentDescription = contentDescription },
|
||||
) {
|
||||
if (fullScreen) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(progressRowHeight)
|
||||
.align(Alignment.Center)
|
||||
.padding(end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = fullScreenTitle,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
text = "$percent%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(progressRowHeight)
|
||||
.padding(start = 12.dp, end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(canvasHeight),
|
||||
) {
|
||||
val centerY = size.height / 2f
|
||||
drawLine(
|
||||
color = trackColor,
|
||||
start = Offset(0f, centerY),
|
||||
end = Offset(size.width, centerY),
|
||||
strokeWidth = trackStroke.toPx(),
|
||||
cap = StrokeCap.Square,
|
||||
)
|
||||
if (progress > 0f) {
|
||||
drawLine(
|
||||
color = readColor,
|
||||
start = Offset(0f, centerY),
|
||||
end = Offset(size.width * progress.coerceIn(0f, 1f), centerY),
|
||||
strokeWidth = readStroke.toPx(),
|
||||
cap = StrokeCap.Square,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
text = "$percent%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Fb2Book.fullScreenProgressTitle(): String {
|
||||
val author = authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor }
|
||||
return "$author. $title"
|
||||
}
|
||||
|
||||
private fun LazyListState.readerProgress(): Float {
|
||||
val layoutInfo = layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
if (totalItems <= 1) return 0f
|
||||
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val lastVisibleItem = visibleItems.lastOrNull()
|
||||
if (
|
||||
lastVisibleItem?.index == totalItems - 1 &&
|
||||
lastVisibleItem.offset + lastVisibleItem.size <= layoutInfo.viewportEndOffset
|
||||
) {
|
||||
return 1f
|
||||
}
|
||||
|
||||
val firstIndex = firstVisibleItemIndex.coerceIn(0, totalItems - 1)
|
||||
val firstVisibleItemSize = visibleItems.firstOrNull { it.index == firstIndex }?.size
|
||||
val itemFraction = firstVisibleItemSize
|
||||
?.takeIf { it > 0 }
|
||||
?.let { (firstVisibleItemScrollOffset.toFloat() / it.toFloat()).coerceIn(0f, 1f) }
|
||||
?: 0f
|
||||
return ((firstIndex + itemFraction) / (totalItems - 1).toFloat()).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactReaderTopBar(
|
||||
title: String,
|
||||
@ -518,6 +730,8 @@ private fun CompactReaderTopBar(
|
||||
readingStatus: BookReadingStatus?,
|
||||
favorite: Boolean,
|
||||
onFavoriteChange: (Boolean) -> Unit,
|
||||
fullScreen: Boolean,
|
||||
onFullScreenChange: (Boolean) -> Unit,
|
||||
showShareAction: Boolean,
|
||||
onShare: () -> Unit,
|
||||
showViewFileAction: Boolean,
|
||||
@ -581,6 +795,16 @@ private fun CompactReaderTopBar(
|
||||
onReaderSettings()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Checkbox(checked = fullScreen, onCheckedChange = null)
|
||||
},
|
||||
text = { Text(strings.fullScreen) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onFullScreenChange(!fullScreen)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
|
||||
@ -57,7 +57,7 @@ internal fun ScanScreen(
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(strings.scan) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message)) }) {
|
||||
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message, state.selectedFilter)) }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary)
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package net.sergeych.toread
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -31,8 +32,39 @@ 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)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LoadingPanel(message)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LoadingOverlay(message: String, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.58f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LoadingPanel(message)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingPanel(message: String) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
@ -449,6 +449,18 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withCo
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadReaderFullScreen(): Boolean = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(ReaderFullScreenFlag)?.toBooleanStrictOrNull() ?: false
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.setAppFlag(ReaderFullScreenFlag, fullScreen.toString())
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
|
||||
@ -461,6 +473,20 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
|
||||
}
|
||||
}
|
||||
|
||||
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(LibraryFilterFlag)
|
||||
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
|
||||
?: LibraryFilter.MyLibrary
|
||||
}
|
||||
}
|
||||
|
||||
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.setAppFlag(LibraryFilterFlag, filter.name)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
|
||||
@ -715,6 +741,8 @@ private fun String.isSupportedBookFile(): Boolean =
|
||||
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
|
||||
private const val ThemeModeFlag = "theme_mode"
|
||||
private const val ReaderFontSettingsFlag = "reader_font_settings"
|
||||
private const val ReaderFullScreenFlag = "reader_full_screen"
|
||||
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
|
||||
private const val LibraryFilterFlag = "library_filter"
|
||||
private const val AppLocaleTagFlag = "app_locale_tag"
|
||||
private const val DownloadsWasScannedFlag = "downloads_was_scanned"
|
||||
|
||||
@ -83,10 +83,26 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) {
|
||||
window.localStorage.setItem(ReaderFontSettingsStorageKey, settings.toStorageValue())
|
||||
}
|
||||
|
||||
actual suspend fun loadReaderFullScreen(): Boolean =
|
||||
window.localStorage.getItem(ReaderFullScreenStorageKey)?.toBooleanStrictOrNull() ?: false
|
||||
|
||||
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) {
|
||||
window.localStorage.setItem(ReaderFullScreenStorageKey, fullScreen.toString())
|
||||
}
|
||||
|
||||
actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
|
||||
|
||||
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
|
||||
|
||||
internal actual suspend fun loadLibraryFilter(): LibraryFilter =
|
||||
window.localStorage.getItem(LibraryFilterStorageKey)
|
||||
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
|
||||
?: LibraryFilter.MyLibrary
|
||||
|
||||
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) {
|
||||
window.localStorage.setItem(LibraryFilterStorageKey, filter.name)
|
||||
}
|
||||
|
||||
actual suspend fun loadAppLocaleTag(): String? =
|
||||
window.localStorage.getItem(AppLocaleStorageKey)?.takeIf { it.isNotBlank() }
|
||||
|
||||
@ -121,7 +137,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
|
||||
actual fun libraryLogPath(): String? = null
|
||||
|
||||
private const val AppLocaleStorageKey = "toread.appLocaleTag"
|
||||
private const val LibraryFilterStorageKey = "toread.libraryFilter"
|
||||
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"
|
||||
private const val ReaderFullScreenStorageKey = "toread.readerFullScreen"
|
||||
|
||||
actual fun formatLibraryLastReadTime(millis: Long): String {
|
||||
val totalMinutes = millis / 60_000L
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.11.2"
|
||||
agp = "8.13.2"
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "26"
|
||||
android-targetSdk = "36"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user