Compare commits

..

No commits in common. "128275402e48d5169907d292c05a569c84a6141d" and "75ba13bde2447ae8147e1d2c8c564abdcfcffa58" have entirely different histories.

35 changed files with 385 additions and 1049 deletions

View File

@ -2,10 +2,6 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val appVersionName = "1.0"
val appVersionCode = 1
val appVersionDisplay = "$appVersionName.$appVersionCode"
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
@ -14,38 +10,6 @@ plugins {
alias(libs.plugins.composeHotReload)
}
abstract class GenerateAppVersionConstantsTask : DefaultTask() {
@get:Input
abstract val versionName: Property<String>
@get:Input
abstract val versionCode: Property<Int>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val outputFile = outputDir.file("net/sergeych/toread/AppVersion.kt").get().asFile
outputFile.parentFile.mkdirs()
outputFile.writeText(
"""
package net.sergeych.toread
internal const val AppVersionName = "${versionName.get()}"
internal const val AppVersionCode = ${versionCode.get()}
internal const val AppVersionDisplay = "${versionName.get()}.${versionCode.get()}"
""".trimIndent() + "\n",
)
}
}
val generateAppVersionConstants by tasks.registering(GenerateAppVersionConstantsTask::class) {
versionName.set(appVersionName)
versionCode.set(appVersionCode)
outputDir.set(layout.buildDirectory.dir("generated/appVersion/commonMain/kotlin"))
}
kotlin {
jvmToolchain(17)
@ -69,9 +33,6 @@ kotlin {
}
sourceSets {
commonMain {
kotlin.srcDir(generateAppVersionConstants)
}
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
@ -108,8 +69,8 @@ android {
applicationId = "net.sergeych.toread"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = appVersionCode
versionName = appVersionName
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
@ -138,8 +99,8 @@ compose.desktop {
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "To Read"
packageVersion = appVersionDisplay
packageName = "net.sergeych.toread"
packageVersion = "1.0.0"
macOS {
iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns"))
@ -150,7 +111,6 @@ compose.desktop {
menu = true
}
linux {
packageName = "to-read"
iconFile.set(project.file("src/jvmMain/resources/icons/icon.png"))
shortcut = true
}

View File

@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "net.sergeych.toread",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "composeApp-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/composeApp-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/composeApp-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}

View File

@ -198,7 +198,7 @@ actual suspend fun scanLibrarySubtree(
scanLibraryContentTree(Uri.parse(path), ::emitProgress)
} else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error(strings.allFilesAccessRequired(path))
error("All files access is required to scan $path.")
}
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
@ -363,7 +363,7 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(D
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, strings.shareBook).apply {
val chooser = Intent.createChooser(intent, "Share book").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
@ -438,18 +438,6 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
}
}
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
}
}
actual suspend fun saveAppLocaleTag(localeTag: String?) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(AppLocaleTagFlag, localeTag?.takeIf { it.isNotBlank() })
}
}
actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
@ -539,7 +527,7 @@ private fun scanLibraryContentTree(
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
val result = runCatching {
val bytes = appContext.contentResolver.openInputStream(document.uri)?.use { it.readBytes() }
?: error(strings.couldNotRead(document.name))
?: error("Could not read ${document.name}")
scanner.importExternalFile(
bytes = bytes,
displayName = document.name,
@ -793,5 +781,4 @@ 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 ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -1,6 +0,0 @@
package net.sergeych.toread
import java.util.Locale
internal actual fun platformLocaleTags(): List<String> =
listOf(Locale.getDefault().toLanguageTag())

View File

@ -82,8 +82,8 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
private fun showDirectoryChoice(result: CompletableDeferred<String?>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
AlertDialog.Builder(this)
.setTitle(strings.chooseLibraryFolder)
.setItems(arrayOf(strings.downloads, strings.otherFolder)) { _, which ->
.setTitle("Choose library folder")
.setItems(arrayOf("Downloads", "Other folder...")) { _, which ->
when (which) {
0 -> chooseDownloadsDirectory(result)
else -> launchSystemDirectoryPicker(result)

View File

@ -146,7 +146,7 @@ private object AndroidReadAloudEngine {
if (status != TextToSpeech.SUCCESS) {
mutableSettingsState.value = mutableSettingsState.value.copy(
loading = false,
message = strings.couldNotLoadAndroidTtsEngines,
message = "Could not load Android TTS engines.",
)
probe?.shutdown()
if (engineProbe === probe) engineProbe = null
@ -319,7 +319,7 @@ private object AndroidReadAloudEngine {
if (status != TextToSpeech.SUCCESS) {
mutableSettingsState.value = mutableSettingsState.value.copy(
loading = false,
message = strings.couldNotLoadSelectedTtsVoices,
message = "Could not load voices for the selected TTS engine.",
)
probe?.shutdown()
if (voiceProbe === probe) voiceProbe = null
@ -349,7 +349,7 @@ private object AndroidReadAloudEngine {
voices = voices,
selectedVoiceId = savedVoice,
loading = false,
message = if (voices.isEmpty()) strings.noOfflineVoices else null,
message = if (voices.isEmpty()) "No offline voices reported by the selected TTS engine." else null,
)
if (savedVoice == null && selectedVoiceId(context) != null) {
context.readAloudPrefs().edit().remove(SelectedVoiceKey).apply()
@ -443,20 +443,20 @@ class ReadAloudService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val playStopAction = if (state.playing) ActionStop else ActionPlay
val playStopTitle = if (state.playing) strings.stop else strings.play
val playStopTitle = if (state.playing) "Stop" else "Play"
val playStopIcon = if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
return NotificationCompat.Builder(this, ChannelId)
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentTitle(strings.readAloud)
.setContentText(strings.readingInBackground)
.setContentTitle("Read aloud")
.setContentText("Reading in the background")
.setContentIntent(contentIntent)
.setOngoing(state.playing)
.setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(android.R.drawable.ic_media_previous, strings.previous, serviceIntent(ActionBack))
.addAction(android.R.drawable.ic_media_previous, "Previous", serviceIntent(ActionBack))
.addAction(playStopIcon, playStopTitle, serviceIntent(playStopAction))
.addAction(android.R.drawable.ic_media_next, strings.next, serviceIntent(ActionForward))
.addAction(android.R.drawable.ic_media_next, "Next", serviceIntent(ActionForward))
.build()
}
@ -481,7 +481,7 @@ class ReadAloudService : Service() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
ChannelId,
strings.readAloud,
"Read aloud",
NotificationManager.IMPORTANCE_LOW,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)

View File

@ -1,3 +0,0 @@
<resources>
<string name="app_name">Читать</string>
</resources>

View File

@ -1,3 +1,3 @@
<resources>
<string name="app_name">To Read</string>
<string name="app_name">toread</string>
</resources>

View File

@ -51,7 +51,6 @@ private data class PendingLibraryDelete(
fun App() {
var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) }
var systemDark by remember { mutableStateOf(isPlatformDarkTheme()) }
var localePreferenceLoaded by remember { mutableStateOf(false) }
var toast by remember { mutableStateOf<AppToastData?>(null) }
var nextToastId by remember { mutableStateOf(0L) }
val scope = rememberCoroutineScope()
@ -79,13 +78,6 @@ fun App() {
themeMode = loadThemeMode()
}
LaunchedEffect(Unit) {
selectedAppLocaleTag = loadAppLocaleTag()?.takeIf { saved ->
availableAppLanguages.any { it.localeTag == saved }
}
localePreferenceLoaded = true
}
LaunchedEffect(toast?.id) {
val current = toast ?: return@LaunchedEffect
delay(current.durationMillis)
@ -97,21 +89,17 @@ fun App() {
MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) {
Box(Modifier.fillMaxSize()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (localePreferenceLoaded) {
BookReaderApp(
onThemeToggle = {
val next = themeMode.next()
themeMode = next
showToast(strings.themeChanged(next))
showToast("Theme: ${next.displayName}")
scope.launch {
saveThemeMode(next)
}
},
onShowToast = ::showToast,
)
} else {
LoadingScreen(strings.loadingOpeningBook)
}
}
AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter))
}
@ -249,15 +237,15 @@ private fun BookReaderApp(
}
onShowToast(
strings.removed(request.title),
strings.undo,
"Removed ${request.title}.",
"Undo",
{
if (pendingDelete?.id == deleteId) {
pendingDeleteJob?.cancel()
pendingDeleteJob = null
pendingDelete = null
restore()
onShowToast(strings.restored(request.title), null, null, DefaultToastDurationMillis)
onShowToast("Restored ${request.title}.", null, null, DefaultToastDurationMillis)
}
},
DeleteUndoDurationMillis,
@ -315,9 +303,9 @@ private fun BookReaderApp(
if (isDownloadsScanPath(path)) {
saveDownloadsWasScanned(true)
}
strings.scanReport(it.scannedFiles, it.importedFiles, it.skippedFiles, it.failedFiles)
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
},
onFailure = { it.message ?: strings.scanFailed },
onFailure = { it.message ?: "Scan failed." },
)
scanJob = null
activeScan = null
@ -341,7 +329,7 @@ private fun BookReaderApp(
}
when (val current = state) {
AppState.LoadingStartup -> LoadingScreen(strings.loadingOpeningBook)
AppState.LoadingStartup -> LoadingScreen("Opening book")
is AppState.Library -> LibraryScreen(
state = current,
activeScan = activeScan,
@ -357,7 +345,7 @@ private fun BookReaderApp(
onStateChange = { state = it },
onStartScan = { path ->
startScan(path)
state = AppState.Library(current.items, path, strings.scanning)
state = AppState.Library(current.items, path, "Scanning...")
},
)
is AppState.Reader -> BookView(

View File

@ -40,7 +40,7 @@ internal suspend fun loadStartupState(): AppState {
val scanPath = try {
defaultLibraryScanPath().orEmpty()
} catch (t: Throwable) {
return AppState.Error(t.message ?: strings.couldNotOpenLibrary)
return AppState.Error(t.message ?: "Could not open library.")
}
loadPlatformOpenBookRequest()?.let { request ->
return runCatching {
@ -51,7 +51,7 @@ internal suspend fun loadStartupState(): AppState {
scanPath = scanPath,
)
}.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName))
AppState.Library(emptyList(), scanPath, it.message ?: "Could not open ${request.displayName}.")
}
}
val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath)
@ -61,7 +61,7 @@ internal suspend fun loadStartupState(): AppState {
return AppState.Library(emptyList(), scanPath)
}
return runCatching {
val bytes = openLibraryBook(activeFileId) ?: error(strings.bookFileNotAvailable)
val bytes = openLibraryBook(activeFileId) ?: error("Book file is not available.")
AppState.Reader(
fileId = activeFileId,
book = Fb2Format.parse(bytes, item.storageUri ?: item.title),
@ -70,7 +70,7 @@ internal suspend fun loadStartupState(): AppState {
)
}.getOrElse {
saveActiveReadingFileId(null)
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotReopenLastBook())
AppState.Library(emptyList(), scanPath, it.message ?: "Could not reopen last book.")
}
}
@ -82,5 +82,5 @@ internal suspend fun loadLibraryState(message: String? = null, scanPath: String?
message = message,
)
}.getOrElse {
AppState.Error(it.message ?: strings.couldNotOpenLibrary)
AppState.Error(it.message ?: "Could not open library.")
}

View File

@ -51,10 +51,10 @@ internal fun BookInfoScreen(
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(strings.bookInfo) },
title = { Text("Book info") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToReader)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader")
}
},
colors = themedTopAppBarColors(),
@ -70,46 +70,46 @@ internal fun BookInfoScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
InfoSection(strings.titleInfo) {
InfoSection("Title Info") {
CoverAndTitle(book, onImageOpen = onImageOpen)
DetailLine(strings.title, book.title)
DetailLine(strings.authors, book.authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor })
DetailLine(strings.language, book.language?.uppercase() ?: strings.notSpecified)
DetailLine(strings.date, book.date ?: strings.notSpecified)
DetailLine(strings.genres, book.genres.joinToString().ifBlank { strings.notSpecified })
DetailLine(strings.source, book.sourceLanguage?.uppercase() ?: strings.notSpecified)
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(strings.statistics) {
InfoSection("Statistics") {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
StatBlock(strings.words, stats.words.formatCompact())
StatBlock(strings.sections, stats.sections.toString())
StatBlock(strings.images, stats.images.toString())
StatBlock("Words", stats.words.formatCompact())
StatBlock("Sections", stats.sections.toString())
StatBlock("Images", stats.images.toString())
}
}
}
item {
InfoSection(strings.lastReadingPosition) {
InfoSection("Last Reading Position") {
val position = extras?.lastReadingPosition
if (position == null) {
Text(strings.noSavedPosition, style = MaterialTheme.typography.bodyMedium)
Text("No saved position", style = MaterialTheme.typography.bodyMedium)
} else {
DetailLine(strings.listItem, position.itemIndex.toString())
DetailLine(strings.scrollOffset, "${position.scrollOffset}px")
DetailLine("List item", position.itemIndex.toString())
DetailLine("Scroll offset", "${position.scrollOffset}px")
}
}
}
item {
InfoSection(strings.bookmarks) {
InfoSection("Bookmarks") {
val bookmarks = extras?.bookmarks.orEmpty()
if (extras == null) {
Text(strings.loading, style = MaterialTheme.typography.bodyMedium)
Text("Loading...", style = MaterialTheme.typography.bodyMedium)
} else if (bookmarks.isEmpty()) {
Text(strings.noBookmarks, style = MaterialTheme.typography.bodyMedium)
Text("No bookmarks", style = MaterialTheme.typography.bodyMedium)
} else {
bookmarks.forEach { bookmark ->
BookmarkInfoLine(bookmark)
@ -118,12 +118,12 @@ internal fun BookInfoScreen(
}
}
item {
InfoSection(strings.notes) {
InfoSection("Notes") {
val notes = extras?.notes.orEmpty()
if (extras == null) {
Text(strings.loading, style = MaterialTheme.typography.bodyMedium)
Text("Loading...", style = MaterialTheme.typography.bodyMedium)
} else if (notes.isEmpty()) {
Text(strings.noNotes, style = MaterialTheme.typography.bodyMedium)
Text("No notes", style = MaterialTheme.typography.bodyMedium)
} else {
notes.forEach { note ->
NoteInfoLine(note)
@ -148,7 +148,7 @@ private fun InfoSection(title: String, content: @Composable ColumnScope.() -> Un
@Composable
private fun BookmarkInfoLine(bookmark: BookmarkInfo) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) {
Text(bookmark.title?.ifBlank { null } ?: strings.bookmark, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
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)
}

View File

@ -88,9 +88,9 @@ internal fun ImageViewer(
fun copyImage() {
scope.launch {
val message = if (copyImageToClipboard(image.bytes, image.mimeType, image.title)) {
strings.imageCopied
"Image copied"
} else {
strings.copyFailed
"Copy failed"
}
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
}
@ -110,7 +110,7 @@ internal fun ImageViewer(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.back)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
image.title,
@ -119,15 +119,15 @@ internal fun ImageViewer(
modifier = Modifier.weight(1f),
)
IconButton(onClick = { setScale(scale * 1.25f) }) {
Icon(Icons.Filled.ZoomIn, contentDescription = strings.zoomIn)
Icon(Icons.Filled.ZoomIn, contentDescription = "Zoom in")
}
IconButton(onClick = { setScale(scale / 1.25f) }, enabled = scale > MinImageScale) {
Icon(Icons.Filled.ZoomOut, contentDescription = strings.zoomOut)
Icon(Icons.Filled.ZoomOut, contentDescription = "Zoom out")
}
IconButton(
onClick = ::copyImage,
) {
Icon(Icons.Filled.ContentCopy, contentDescription = strings.copyImage)
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy image")
}
}
}

View File

@ -14,6 +14,6 @@ internal suspend fun deleteLibraryBook(fileId: String, title: String): LibraryDe
val deleted = runCatching { deleteLibraryItem(fileId) }.getOrDefault(false)
return LibraryDeleteResult(
deleted = deleted,
message = if (deleted) strings.removed(title) else strings.couldNotRemove(title),
message = if (deleted) "Removed $title." else "Could not remove $title.",
)
}

View File

@ -145,10 +145,6 @@ expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
expect suspend fun loadAppLocaleTag(): String?
expect suspend fun saveAppLocaleTag(localeTag: String?)
expect suspend fun loadDownloadsWasScanned(): Boolean
expect suspend fun saveDownloadsWasScanned(scanned: Boolean)

View File

@ -1,7 +1,5 @@
package net.sergeych.toread
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -26,7 +24,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.KeyboardArrowDown
@ -57,7 +54,6 @@ 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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
@ -67,9 +63,9 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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
@ -93,7 +89,6 @@ internal fun LibraryScreen(
) -> Unit,
) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) }
@ -103,24 +98,24 @@ internal fun LibraryScreen(
var recentlyAddedItems by remember(state.items) { mutableStateOf<List<LibraryItem>>(emptyList()) }
var wasScanning by remember { mutableStateOf(false) }
var settingsMenuOpen by remember { mutableStateOf(false) }
var localeMenuOpen by remember { mutableStateOf(false) }
var autoScanDownloads by remember { mutableStateOf(true) }
var autoScanSettingLoaded by remember { mutableStateOf(false) }
var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
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 readingNowCollapsed by remember { mutableStateOf(false) }
var favoritesCollapsed by remember { mutableStateOf(false) }
var toReadCollapsed by remember { mutableStateOf(false) }
var readCollapsed by remember { mutableStateOf(false) }
var recentlyAddedCollapsed by remember { mutableStateOf(false) }
var myLibraryCollapsed by remember { mutableStateOf(false) }
var notInterestedCollapsed by remember { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank()
val libraryItems = items.filterNot { it.fileId in hiddenFileIds }
val visibleSearchResults = searchResults.filterNot { it.fileId in hiddenFileIds }
val sourceItems = if (searchActive) visibleSearchResults else libraryItems
val recentlyAdded = recentlyAddedItems.filterNot { it.fileId in hiddenFileIds }
val visibleItems = selectedFilter.apply(sourceItems, recentlyAdded, searchActive)
val canLoadMore = !searchActive && selectedFilter.usesPagedLibrary && !endReached
val visibleItems = if (searchActive) visibleSearchResults else libraryItems
suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return
@ -148,7 +143,7 @@ internal fun LibraryScreen(
nextOffset = offset + page.size
endReached = page.size < limit
} catch (t: Throwable) {
message = t.message ?: strings.couldNotLoadLibrary
message = t.message ?: "Could not load library."
endReached = true
} finally {
loadingPage = false
@ -194,7 +189,7 @@ internal fun LibraryScreen(
loadRecentlyAdded()
}
} else {
message = strings.couldNotUpdate(item.title)
message = "Could not update ${item.title}."
}
}
@ -218,23 +213,22 @@ internal fun LibraryScreen(
loadRecentlyAdded()
}
} else {
message = strings.couldNotUpdate(item.title)
message = "Could not update ${item.title}."
}
}
fun rescanAllLibrary() {
settingsMenuOpen = false
localeMenuOpen = false
scope.launch {
busy = true
message = strings.rescanningLibrary
message = "Rescanning library..."
try {
val report = rescanAllLibraryBooks()
refresh(
strings.rescanReport(report.scannedFiles, report.updatedFiles, report.failedFiles),
"Rescanned ${report.scannedFiles}, updated ${report.updatedFiles}, failed ${report.failedFiles}.",
)
} catch (t: Throwable) {
message = t.message ?: strings.libraryRescanFailed
message = t.message ?: "Library rescan failed."
} finally {
busy = false
}
@ -247,12 +241,6 @@ internal fun LibraryScreen(
searching = false
}
fun closeSearch() {
clearSearch()
focusManager.clearFocus()
searchFocused = false
}
LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) {
loadPage(reset = true)
@ -279,26 +267,12 @@ internal fun LibraryScreen(
.onFailure {
if (searchText == query) {
searchResults = emptyList()
message = it.message ?: strings.searchFailed
message = it.message ?: "Search failed."
}
}
if (searchText == query) searching = false
}
LaunchedEffect(searchFocused) {
if (searchFocused) {
settingsMenuOpen = false
localeMenuOpen = false
}
}
LaunchedEffect(searchActive, loadingPage, endReached, libraryItems, recentlyAdded) {
val libraryDataLoaded = endReached || libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty()
if (!filterChosenByUser && !searchActive && !loadingPage && libraryDataLoaded) {
selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded)
}
}
LaunchedEffect(Unit) {
autoScanDownloads = loadScanDownloadsAutomatically()
autoScanSettingLoaded = true
@ -316,7 +290,7 @@ internal fun LibraryScreen(
if (loadDownloadsWasScanned()) {
downloadsScanPath()?.let { path ->
backgroundDownloadsRescanStarted = true
message = strings.scanningDownloads
message = "Scanning Downloads..."
onStartScan(path)
}
}
@ -341,45 +315,20 @@ internal fun LibraryScreen(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth().animateContentSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(visible = !searchFocused) {
LibraryFilterSelect(
selected = selectedFilter,
onSelected = {
filterChosenByUser = true
selectedFilter = it
},
)
}
LibrarySearchField(
value = searchText,
focused = searchFocused,
onValueChange = { searchText = it },
onClear = ::clearSearch,
onClose = ::closeSearch,
onFocusChanged = { searchFocused = it },
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(),
)
}
},
colors = themedTopAppBarColors(),
actions = {
AnimatedVisibility(visible = !searchFocused) {
Box {
IconButton(onClick = { settingsMenuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = strings.libraryOptions)
Icon(Icons.Filled.MoreVert, contentDescription = "Library options")
}
DropdownMenu(
expanded = settingsMenuOpen,
onDismissRequest = {
settingsMenuOpen = false
localeMenuOpen = false
},
) {
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) {
// DropdownMenuItem(
// text = { Text("Rescan all library") },
// enabled = !busy && activeScan == null,
@ -390,7 +339,7 @@ internal fun LibraryScreen(
leadingIcon = {
Checkbox(checked = autoScanDownloads, onCheckedChange = null)
},
text = { Text(strings.scanDownloadsAutomatically) },
text = { Text("Scan Downloads automatically") },
onClick = {
val next = !autoScanDownloads
autoScanDownloads = next
@ -398,57 +347,6 @@ internal fun LibraryScreen(
scope.launch { saveScanDownloadsAutomatically(next) }
},
)
HorizontalDivider()
Box {
DropdownMenuItem(
text = { Text(strings.locale) },
trailingIcon = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
},
onClick = { localeMenuOpen = true },
)
DropdownMenu(expanded = localeMenuOpen, onDismissRequest = { localeMenuOpen = false }) {
availableAppLanguages.forEach { language ->
val checked = selectedAppLocaleTag == language.localeTag
DropdownMenuItem(
leadingIcon = {
if (checked) {
Icon(Icons.Filled.Check, contentDescription = null)
}
},
text = { Text(strings.appLanguageName(language)) },
onClick = {
selectedAppLocaleTag = language.localeTag
localeMenuOpen = false
settingsMenuOpen = false
scope.launch { saveAppLocaleTag(language.localeTag) }
},
)
}
HorizontalDivider()
DropdownMenuItem(
leadingIcon = {
if (selectedAppLocaleTag == null) {
Icon(Icons.Filled.Check, contentDescription = null)
}
},
text = { Text(strings.systemLocale) },
onClick = {
selectedAppLocaleTag = null
localeMenuOpen = false
settingsMenuOpen = false
scope.launch { saveAppLocaleTag(null) }
},
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = { Text(strings.appVersion(AppVersionDisplay)) },
enabled = false,
onClick = {},
)
}
}
}
},
@ -456,7 +354,7 @@ internal fun LibraryScreen(
},
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToScan) {
Icon(Icons.Filled.Add, contentDescription = strings.scanFolder)
Icon(Icons.Filled.Add, contentDescription = "Scan folder")
}
},
) {
@ -465,21 +363,11 @@ internal fun LibraryScreen(
.fillMaxSize()
.padding(it)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when {
event.key == Key.Escape && searchText.isNotBlank() -> {
if (event.type == KeyEventType.KeyDown && 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
} else {
false
}
}
.background(readerBackground()),
@ -489,14 +377,11 @@ internal fun LibraryScreen(
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (visibleItems.isEmpty() && !canLoadMore) {
} else if (visibleItems.isEmpty()) {
if (searchActive) {
EmptySearchPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else {
EmptyLibraryPane(
filter = selectedFilter,
modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp),
)
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
}
} else {
LazyColumn(
@ -510,7 +395,7 @@ internal fun LibraryScreen(
busy = true
try {
val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error(strings.bookFileNotAvailable)
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
var readerLibraryItems = visibleItems
refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem ->
@ -537,7 +422,7 @@ internal fun LibraryScreen(
message = message,
)
}.getOrElse {
AppState.Library(visibleItems, state.scanPath, it.message ?: strings.couldNotOpenBook)
AppState.Library(visibleItems, state.scanPath, it.message ?: "Could not open book.")
}
onStateChange(next)
} finally {
@ -549,7 +434,7 @@ internal fun LibraryScreen(
scope.launch {
busy = true
try {
applyReadingStatus(item, BookReadingStatus.READ, strings.markedAsRead(item.title))
applyReadingStatus(item, BookReadingStatus.READ, "Marked ${item.title} as read.")
} finally {
busy = false
}
@ -559,7 +444,7 @@ internal fun LibraryScreen(
scope.launch {
busy = true
try {
applyReadingStatus(item, BookReadingStatus.NEW, strings.markedAsUnread(item.title))
applyReadingStatus(item, BookReadingStatus.NEW, "Marked ${item.title} as unread.")
} finally {
busy = false
}
@ -569,7 +454,7 @@ internal fun LibraryScreen(
scope.launch {
busy = true
try {
applyReadingStatus(item, BookReadingStatus.NEW, strings.removedMarks(item.title))
applyReadingStatus(item, BookReadingStatus.NEW, "Removed marks from ${item.title}.")
} finally {
busy = false
}
@ -579,7 +464,7 @@ internal fun LibraryScreen(
scope.launch {
busy = true
try {
applyReadingStatus(item, BookReadingStatus.TO_READ, strings.markedToRead(item.title))
applyReadingStatus(item, BookReadingStatus.TO_READ, "Marked ${item.title} to read.")
} finally {
busy = false
}
@ -589,7 +474,7 @@ internal fun LibraryScreen(
scope.launch {
busy = true
try {
applyReadingStatus(item, BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested(item.title))
applyReadingStatus(item, BookReadingStatus.NOT_INTERESTED, "Marked ${item.title} as not interested.")
} finally {
busy = false
}
@ -599,7 +484,7 @@ internal fun LibraryScreen(
scope.launch {
busy = true
try {
val label = if (favorite) strings.addedToFavorites(item.title) else strings.removedFromFavorites(item.title)
val label = if (favorite) "Added ${item.title} to favorites." else "Removed ${item.title} from favorites."
applyFavorite(item, favorite, label)
} finally {
busy = false
@ -636,8 +521,8 @@ internal fun LibraryScreen(
},
)
fun LazyListScope.libraryRows(rowItems: List<LibraryItem>) {
itemsIndexed(rowItems, key = { _, item -> item.fileId }) { _, item ->
fun LazyListScope.libraryRows(sectionKey: String, sectionItems: List<LibraryItem>) {
itemsIndexed(sectionItems, key = { _, item -> "$sectionKey-${item.fileId}" }) { _, item ->
LibraryRow(
item = item,
coverCache = coverCache,
@ -647,9 +532,98 @@ internal fun LibraryScreen(
}
}
libraryRows(visibleItems)
if (searchActive) {
libraryRows("search", visibleItems)
} else {
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
val favorites = visibleItems.filter { it.favorite }
val toRead = visibleItems.filter { it.readingStatus == BookReadingStatus.TO_READ }
val read = visibleItems.filter { it.readingStatus == BookReadingStatus.READ }
val recentlyAdded = recentlyAddedItems.filter {
it.fileId !in hiddenFileIds && it.readingStatus == BookReadingStatus.NEW
}
val recentlyAddedIds = recentlyAdded.mapTo(mutableSetOf()) { it.fileId }
val myLibrary = visibleItems.filter {
it.fileId !in recentlyAddedIds &&
it.readingStatus != BookReadingStatus.READING &&
it.readingStatus != BookReadingStatus.TO_READ &&
it.readingStatus != BookReadingStatus.READ &&
it.readingStatus != BookReadingStatus.NOT_INTERESTED
}
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
if (canLoadMore) {
librarySection(
key = "reading",
title = "reading now",
itemCount = readingNow.size,
displayCount = readingNow.size.takeIf { endReached },
collapsed = readingNowCollapsed,
onCollapsedChange = { readingNowCollapsed = it },
) {
libraryRows("reading", readingNow)
}
librarySection(
key = "favorites",
title = "favorites",
itemCount = favorites.size,
displayCount = favorites.size.takeIf { endReached },
collapsed = favoritesCollapsed,
onCollapsedChange = { favoritesCollapsed = it },
) {
libraryRows("favorites", favorites)
}
librarySection(
key = "to-read",
title = "to read",
itemCount = toRead.size,
displayCount = toRead.size.takeIf { endReached },
collapsed = toReadCollapsed,
onCollapsedChange = { toReadCollapsed = it },
) {
libraryRows("to-read", toRead)
}
librarySection(
key = "recently-added",
title = "recently added",
itemCount = recentlyAdded.size,
displayCount = null,
collapsed = recentlyAddedCollapsed,
onCollapsedChange = { recentlyAddedCollapsed = it },
) {
libraryRows("recently-added", recentlyAdded)
}
librarySection(
key = "library",
title = "my library",
itemCount = myLibrary.size,
displayCount = myLibrary.size.takeIf { endReached },
collapsed = myLibraryCollapsed,
onCollapsedChange = { myLibraryCollapsed = it },
) {
libraryRows("library", myLibrary)
}
librarySection(
key = "read",
title = "read",
itemCount = read.size,
displayCount = read.size.takeIf { endReached },
collapsed = readCollapsed,
onCollapsedChange = { readCollapsed = it },
) {
libraryRows("read", read)
}
librarySection(
key = "not-interested",
title = "not interested",
itemCount = notInterested.size,
displayCount = notInterested.size.takeIf { endReached },
collapsed = notInterestedCollapsed,
onCollapsedChange = { notInterestedCollapsed = it },
) {
libraryRows("not-interested", notInterested)
}
}
if (!searchActive && !endReached) {
item(key = "load-more") {
LaunchedEffect(nextOffset, items.size) {
if (!loadingPage) loadPage()
@ -676,60 +650,11 @@ internal fun LibraryScreen(
}
}
@Composable
private fun LibraryFilterSelect(
selected: LibraryFilter,
onSelected: (LibraryFilter) -> Unit,
modifier: Modifier = Modifier,
) {
var open by remember { mutableStateOf(false) }
Box(modifier = modifier) {
Row(
modifier = Modifier
.height(42.dp)
.clip(RoundedCornerShape(16.dp))
.clickable { open = true }
.background(MaterialTheme.colorScheme.surface)
.padding(start = 14.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
selected.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = strings.chooseLibraryFilter,
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(18.dp),
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
LibraryFilter.entries.forEach { filter ->
DropdownMenuItem(
text = { Text(filter.label) },
onClick = {
open = false
onSelected(filter)
},
)
}
}
}
}
@Composable
private fun LibrarySearchField(
value: String,
focused: Boolean,
onValueChange: (String) -> Unit,
onClear: () -> Unit,
onClose: () -> Unit,
onFocusChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(16.dp)
@ -740,28 +665,18 @@ private fun LibrarySearchField(
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when {
event.key == Key.Escape && value.isNotBlank() -> {
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
onClear()
true
}
event.key == Key.Escape && focused -> {
onClose()
true
}
event.key.isEnterKey() && focused && value.isBlank() -> {
onClose()
true
}
else -> false
} else {
false
}
},
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Search,
contentDescription = strings.searchLibrary,
contentDescription = "Search library",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline,
)
@ -777,43 +692,32 @@ private fun LibrarySearchField(
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) },
modifier = Modifier.fillMaxWidth(),
)
if (value.isBlank()) {
Text(
strings.searchLibrary,
"Search library",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
maxLines = 1,
)
}
}
if (value.isNotBlank() || focused) {
IconButton(
onClick = {
if (value.isBlank()) onClose() else onClear()
},
modifier = Modifier.size(30.dp),
) {
Icon(
Icons.Filled.Close,
contentDescription = if (value.isBlank()) strings.closeSearch else strings.clearSearch,
modifier = Modifier.size(17.dp),
)
if (value.isNotBlank()) {
IconButton(onClick = onClear, modifier = Modifier.size(30.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(17.dp))
}
}
}
}
@Composable
private fun EmptyLibraryPane(filter: LibraryFilter, modifier: Modifier = Modifier) {
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(strings.noBooksIn(filter.label), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(strings.scanFolderOrChooseFilter, style = MaterialTheme.typography.bodyMedium)
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)
}
}
}
@ -822,7 +726,60 @@ private fun EmptyLibraryPane(filter: LibraryFilter, modifier: Modifier = Modifie
@Composable
private fun EmptySearchPane(modifier: Modifier = Modifier) {
Box(modifier, contentAlignment = Alignment.Center) {
Text(strings.noMatches, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline)
Text("No matches", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline)
}
}
private fun LazyListScope.librarySection(
key: String,
title: String,
itemCount: Int,
displayCount: Int?,
collapsed: Boolean,
onCollapsedChange: (Boolean) -> Unit,
content: LazyListScope.() -> Unit,
) {
if (itemCount == 0) return
item(key = "section-$key") {
LibrarySectionHeader(
text = title,
count = displayCount,
collapsed = collapsed,
onToggle = { onCollapsedChange(!collapsed) },
)
}
if (!collapsed) {
content()
}
}
@Composable
private fun LibrarySectionHeader(
text: String,
count: Int?,
collapsed: Boolean,
onToggle: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(top = 10.dp, bottom = 4.dp, start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
if (collapsed) Icons.AutoMirrored.Filled.KeyboardArrowRight else Icons.Filled.KeyboardArrowDown,
contentDescription = if (collapsed) "Expand $text" else "Collapse $text",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(18.dp),
)
Text(
if (count == null) text else "$text ($count)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center,
)
}
}
@ -847,7 +804,7 @@ private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modi
fontWeight = FontWeight.SemiBold,
)
Text(
strings.importedSkippedFailed(progress.importedFiles, progress.skippedFiles, progress.failedFiles),
"Imported ${progress.importedFiles}, skipped ${progress.skippedFiles}, failed ${progress.failedFiles}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
@ -893,14 +850,14 @@ private fun LibraryRow(
if (item.favorite) {
Icon(
Icons.Filled.Favorite,
contentDescription = strings.favorite,
contentDescription = "Favorite",
tint = Color(0xFFD32F2F),
modifier = Modifier.size(favoriteIconSize),
)
}
}
Text(
item.authors.joinToString().ifBlank { strings.unknownAuthor },
item.authors.joinToString().ifBlank { "Unknown author" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
@ -915,11 +872,11 @@ private fun LibraryRow(
}
Box {
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
Icon(Icons.Filled.MoreVert, contentDescription = strings.bookMenuFor(item.title))
Icon(Icons.Filled.MoreVert, contentDescription = "Book menu for ${item.title}")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(strings.open) },
text = { Text("Open") },
onClick = {
menuOpen = false
actions.onOpen()
@ -928,7 +885,7 @@ private fun LibraryRow(
HorizontalDivider()
if (item.readingStatus != BookReadingStatus.READ) {
DropdownMenuItem(
text = { Text(strings.markAsRead) },
text = { Text("Mark as read") },
onClick = {
menuOpen = false
actions.onMarkAsRead()
@ -937,7 +894,7 @@ private fun LibraryRow(
}
if (item.readingStatus == BookReadingStatus.READ) {
DropdownMenuItem(
text = { Text(strings.markAsUnread) },
text = { Text("Mark as unread") },
onClick = {
menuOpen = false
actions.onMarkAsUnread()
@ -946,7 +903,7 @@ private fun LibraryRow(
}
if (item.readingStatus != BookReadingStatus.TO_READ) {
DropdownMenuItem(
text = { Text(strings.markToRead) },
text = { Text("Mark to read") },
onClick = {
menuOpen = false
actions.onMarkToRead()
@ -955,7 +912,7 @@ private fun LibraryRow(
}
if (item.readingStatus != BookReadingStatus.NEW) {
DropdownMenuItem(
text = { Text(if (item.readingStatus == BookReadingStatus.TO_READ) strings.removeToRead else strings.removeMarks) },
text = { Text(if (item.readingStatus == BookReadingStatus.TO_READ) "Remove to read" else "Remove marks") },
onClick = {
menuOpen = false
actions.onRemoveMarks()
@ -963,14 +920,14 @@ private fun LibraryRow(
)
}
DropdownMenuItem(
text = { Text(if (item.favorite) strings.removeFavorite else strings.addFavorite) },
text = { Text(if (item.favorite) "Remove favorite" else "Add favorite") },
onClick = {
menuOpen = false
actions.onFavoriteChange(!item.favorite)
},
)
DropdownMenuItem(
text = { Text(strings.notInterested) },
text = { Text("Not interested") },
onClick = {
menuOpen = false
actions.onNotInterested()
@ -978,7 +935,7 @@ private fun LibraryRow(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(strings.delete) },
text = { Text("Delete") },
onClick = {
menuOpen = false
actions.onDelete()
@ -1001,67 +958,6 @@ private data class LibraryItemActions(
val onDelete: () -> Unit,
)
private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) {
ReadingNow,
RecentlyAdded(usesPagedLibrary = false),
MyLibrary,
ToRead,
Favorites,
Read,
NotInterested,
;
fun apply(
sourceItems: List<LibraryItem>,
recentlyAddedItems: List<LibraryItem>,
searchActive: Boolean,
): List<LibraryItem> {
val recentlyAddedIds = recentlyAddedItems
.filter { it.readingStatus == BookReadingStatus.NEW }
.mapTo(mutableSetOf()) { it.fileId }
return when (this) {
ReadingNow -> sourceItems.filter { it.readingStatus == BookReadingStatus.READING }
RecentlyAdded -> if (searchActive) {
sourceItems.filter { it.fileId in recentlyAddedIds && it.readingStatus == BookReadingStatus.NEW }
} else {
recentlyAddedItems.filter { it.readingStatus == BookReadingStatus.NEW }
}
MyLibrary -> sourceItems.filter {
it.fileId !in recentlyAddedIds &&
it.readingStatus != BookReadingStatus.READING &&
it.readingStatus != BookReadingStatus.TO_READ &&
it.readingStatus != BookReadingStatus.READ &&
it.readingStatus != BookReadingStatus.NOT_INTERESTED
}
ToRead -> sourceItems.filter { it.readingStatus == BookReadingStatus.TO_READ }
Favorites -> sourceItems.filter { it.favorite }
Read -> sourceItems.filter { it.readingStatus == BookReadingStatus.READ }
NotInterested -> sourceItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
}
}
}
private val LibraryFilter.label: String
get() = when (this) {
LibraryFilter.ReadingNow -> strings.filterReadingNow
LibraryFilter.RecentlyAdded -> strings.filterRecentlyAdded
LibraryFilter.MyLibrary -> strings.filterMyLibrary
LibraryFilter.ToRead -> strings.filterToRead
LibraryFilter.Favorites -> strings.favorite
LibraryFilter.Read -> strings.filterRead
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,
@ -1102,16 +998,25 @@ private fun LibraryCover(
private fun LibraryItem.libraryMetadataLine(): String =
listOfNotNull(
strings.favorite.takeIf { favorite },
strings.readingStatus(readingStatus),
"Favorite".takeIf { favorite },
readingStatus.displayLabel,
lastReadAt?.formatLastRead(),
date?.yearOrRaw(),
language?.uppercase(),
format?.uppercase(),
sizeBytes?.formatBytes(),
).joinToString(" | ").ifBlank { strings.noMetadata }
).joinToString(" | ").ifBlank { "No metadata" }
private fun Long.formatLastRead(): String = "${strings.lastReadPrefix} ${formatLibraryLastReadTime(this)}"
private val BookReadingStatus.displayLabel: String
get() = when (this) {
BookReadingStatus.NEW -> "New"
BookReadingStatus.TO_READ -> "To read"
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
@ -1126,16 +1031,14 @@ private fun Long.formatBytes(): String =
private fun List<LibraryItem>.replaceLibraryItem(item: LibraryItem): List<LibraryItem> =
map { current -> if (current.fileId == item.fileId) item else current }
private fun Key.isEnterKey(): Boolean = this == Key.Enter || this == Key.NumPadEnter
private fun LibraryScanProgress.toCatalogScanMessage(): String {
val total = totalFiles ?: return strings.scannedBooks(scannedFiles)
val total = totalFiles ?: return "Scanned $scannedFiles books"
val percent = if (total <= 0) {
100
} else {
((scannedFiles.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100)
}
return strings.scannedProgress(scannedFiles, total, percent)
return "Scanned $scannedFiles of $total, $percent% done"
}
private const val LibraryPageSize: Int = 50

View File

@ -1,429 +0,0 @@
package net.sergeych.toread
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt
internal enum class AppLanguage(val localeTag: String) {
English("en"),
Russian("ru"),
}
internal val availableAppLanguages: List<AppLanguage> = AppLanguage.entries
internal var selectedAppLocaleTag: String? by mutableStateOf(null)
internal val appLanguage: AppLanguage
get() = if (effectiveAppLocaleTags().any { it.isRussianLanguageTag() }) {
AppLanguage.Russian
} else {
AppLanguage.English
}
internal val strings: AppStrings
get() = when (appLanguage) {
AppLanguage.English -> EnglishStrings
AppLanguage.Russian -> RussianStrings
}
internal expect fun platformLocaleTags(): List<String>
private fun effectiveAppLocaleTags(): List<String> =
selectedAppLocaleTag?.let(::listOf) ?: platformLocaleTags()
private fun String.isRussianLanguageTag(): Boolean =
substringBefore('-').substringBefore('_').equals("ru", ignoreCase = true)
internal open class AppStrings {
open val appName = "To Read"
open val loadingOpeningBook = "Opening book"
open val scanning = "Scanning..."
open val scanningDownloads = "Scanning Downloads..."
open val undo = "Undo"
open val retry = "Retry"
open val done = "Done"
open val libraryError = "Library error"
open val couldNotOpenLibrary = "Could not open library."
open val couldNotLoadLibrary = "Could not load library."
open val couldNotOpenBook = "Could not open book."
open val couldNotUpdateBook = "Could not update book."
open val bookFileNotAvailable = "Book file is not available."
open val scanFailed = "Scan failed."
open val searchFailed = "Search failed."
open val libraryRescanFailed = "Library rescan failed."
open val rescanningLibrary = "Rescanning library..."
open val theme = "Theme"
open val themeLight = "Light"
open val themeDark = "Dark"
open val themeSystem = "System"
open val libraryOptions = "Library options"
open val scanDownloadsAutomatically = "Scan Downloads automatically"
open val locale = "Locale"
open val systemLocale = "Default"
open fun appVersion(version: String): String = "Version $version"
open val scanFolder = "Scan folder"
open val chooseLibraryFilter = "Choose library filter"
open val searchLibrary = "Search library"
open val closeSearch = "Close search"
open val clearSearch = "Clear search"
open val noMatches = "No matches"
open val scanFolderOrChooseFilter = "Scan a folder or choose another library filter."
open val favorite = "Favorite"
open val unknownAuthor = "Unknown author"
open val noMetadata = "No metadata"
open val lastReadPrefix = "Last read"
open val open = "Open"
open val markAsRead = "Mark as read"
open val markAsUnread = "Mark as unread"
open val markToRead = "Mark to read"
open val removeToRead = "Remove to read"
open val removeMarks = "Remove marks"
open val notInterested = "Not interested"
open val clearMarks = "Clear marks"
open val addFavorite = "Add favorite"
open val removeFavorite = "Remove favorite"
open val delete = "Delete"
open val filterReadingNow = "Reading now"
open val filterRecentlyAdded = "Recently added"
open val filterMyLibrary = "My library"
open val filterToRead = "To read"
open val filterRead = "Read"
open val scan = "Scan"
open val rootFolder = "Root folder"
open val choose = "Choose"
open val logPrefix = "Log"
open val bookInfo = "Book info"
open val back = "Back"
open val backToLibrary = "Back to library"
open val backToReader = "Back to reader"
open val titleInfo = "Title Info"
open val title = "Title"
open val authors = "Authors"
open val language = "Language"
open val date = "Date"
open val genres = "Genres"
open val source = "Source"
open val translator = "Translator"
open val notSpecified = "Not specified"
open val statistics = "Statistics"
open val words = "Words"
open val sections = "Sections"
open val images = "Images"
open val lastReadingPosition = "Last Reading Position"
open val noSavedPosition = "No saved position"
open val listItem = "List item"
open val scrollOffset = "Scroll offset"
open val bookmarks = "Bookmarks"
open val bookmark = "Bookmark"
open val noBookmarks = "No bookmarks"
open val notes = "Notes"
open val noNotes = "No notes"
open val loading = "Loading..."
open val noImage = "No image"
open val readerTheme = "Theme"
open val readAloud = "Read aloud"
open val readerMenu = "Book reader menu"
open val info = "Info..."
open val share = "Share"
open val viewFile = "View file"
open val previousSentence = "Previous sentence"
open val stopReading = "Stop reading"
open val startReading = "Start reading"
open val nextSentence = "Next sentence"
open val readAloudSettings = "Read aloud settings"
open val ttsEngine = "TTS engine"
open val systemDefault = "System default"
open val offlineVoice = "Offline voice"
open val autoRussian = "Auto Russian"
open val loadingVoices = "Loading voices..."
open val imageCopied = "Image copied"
open val copyFailed = "Copy failed"
open val zoomIn = "Zoom in"
open val zoomOut = "Zoom out"
open val copyImage = "Copy image"
open val chooseLibraryFolder = "Choose library folder"
open val downloads = "Downloads"
open val otherFolder = "Other folder..."
open val play = "Play"
open val stop = "Stop"
open val previous = "Previous"
open val next = "Next"
open val readingInBackground = "Reading in the background"
open val shareBook = "Share book"
open val couldNotLoadAndroidTtsEngines = "Could not load Android TTS engines."
open val couldNotLoadSelectedTtsVoices = "Could not load voices for the selected TTS engine."
open val noOfflineVoices = "No offline voices reported by the selected TTS engine."
open fun themeChanged(mode: ThemeMode): String = "$theme: ${mode.localizedName()}"
open fun removed(title: String): String = "Removed $title."
open fun restored(title: String): String = "Restored $title."
open fun couldNotRemove(title: String): String = "Could not remove $title."
open fun couldNotOpen(displayName: String): String = "Could not open $displayName."
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 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 =
buildString {
append("Checking")
currentFile?.takeIf(String::isNotBlank)?.let { append(" $it") }
append(". Found $scanned supported files")
append(", imported $imported")
append(", skipped $skipped")
append(", failed $failed.")
}
open fun importedSkippedFailed(imported: Int, skipped: Int, failed: Int): String =
"Imported $imported, skipped $skipped, failed $failed"
open fun scannedBooks(scanned: Int): String = "Scanned $scanned books"
open fun scannedProgress(scanned: Int, total: Int, percent: Int): String =
"Scanned $scanned of $total, $percent% done"
open fun noBooksIn(filterLabel: String): String = "No books in ${filterLabel.lowercase()}"
open fun bookMenuFor(title: String): String = "Book menu for $title"
open fun markedAsRead(title: String? = null): String = title?.let { "Marked $it as read." } ?: "Marked as read."
open fun markedAsUnread(title: String): String = "Marked $title as unread."
open fun markedToRead(title: String? = null): String = title?.let { "Marked $it to read." } ?: "Marked to read."
open fun markedNotInterested(title: String? = null): String =
title?.let { "Marked $it as not interested." } ?: "Marked as not interested."
open fun removedMarks(title: String? = null): String = title?.let { "Removed marks from $it." } ?: "Cleared marks."
open fun addedToFavorites(title: String? = null): String =
title?.let { "Added $it to favorites." } ?: "Added to favorites."
open fun removedFromFavorites(title: String? = null): String =
title?.let { "Removed $it from favorites." } ?: "Removed from favorites."
open fun couldNotUpdate(title: String): String = "Could not update $title."
open fun shareOpened(): String = "Share opened."
open fun couldNotShareBook(): String = "Could not share book."
open fun openedFileLocation(): String = "Opened file location."
open fun couldNotOpenFileLocation(): String = "Could not open file location."
open fun allFilesAccessRequired(path: String): String = "All files access is required to scan $path."
open fun couldNotRead(name: String): String = "Could not read $name"
open fun progressLabel(progress: Double?): String =
progress?.let { "Progress ${(it * 100).roundToInt()}%" } ?: "Progress not recorded"
open fun appLanguageName(language: AppLanguage): String =
when (language) {
AppLanguage.English -> "English"
AppLanguage.Russian -> "Russian"
}
open fun sectionFallback(index: Int): String = "Section ${index + 1}"
open fun readingStatus(status: BookReadingStatus): String =
when (status) {
BookReadingStatus.NEW -> "New"
BookReadingStatus.TO_READ -> "To read"
BookReadingStatus.READING -> "Reading"
BookReadingStatus.READ -> "Read"
BookReadingStatus.NOT_INTERESTED -> "Not interested"
}
}
internal object EnglishStrings : AppStrings()
internal object RussianStrings : AppStrings() {
override val appName = "Читать"
override val loadingOpeningBook = "Открываем книгу"
override val scanning = "Сканируем..."
override val scanningDownloads = "Сканируем Загрузки..."
override val undo = "Отменить"
override val retry = "Повторить"
override val done = "Готово"
override val libraryError = "Ошибка библиотеки"
override val couldNotOpenLibrary = "Не удалось открыть библиотеку."
override val couldNotLoadLibrary = "Не удалось загрузить библиотеку."
override val couldNotOpenBook = "Не удалось открыть книгу."
override val couldNotUpdateBook = "Не удалось обновить книгу."
override val bookFileNotAvailable = "Файл книги недоступен."
override val scanFailed = "Сканирование не удалось."
override val searchFailed = "Поиск не удался."
override val libraryRescanFailed = "Повторное сканирование библиотеки не удалось."
override val rescanningLibrary = "Пересканируем библиотеку..."
override val theme = "Тема"
override val themeLight = "светлая"
override val themeDark = "темная"
override val themeSystem = "системная"
override val libraryOptions = "Параметры библиотеки"
override val scanDownloadsAutomatically = "Автоматически сканировать Загрузки"
override val locale = "Локализация"
override val systemLocale = "Системная"
override fun appVersion(version: String): String = "Версия $version"
override val scanFolder = "Сканировать папку"
override val chooseLibraryFilter = "Выбрать фильтр библиотеки"
override val searchLibrary = "Поиск"
override val closeSearch = "Закрыть поиск"
override val clearSearch = "Очистить поиск"
override val noMatches = "Ничего не найдено"
override val scanFolderOrChooseFilter = "Просканируйте папку или выберите другой фильтр библиотеки."
override val favorite = "Избранное"
override val unknownAuthor = "Автор неизвестен"
override val noMetadata = "Нет метаданных"
override val lastReadPrefix = "Читали"
override val open = "Открыть"
override val markAsRead = "Отметить прочитанной"
override val markAsUnread = "Отметить непрочитанной"
override val markToRead = "Отложить к чтению"
override val removeToRead = "Убрать из отложенного"
override val removeMarks = "Снять отметки"
override val notInterested = "Не интересно"
override val clearMarks = "Очистить отметки"
override val addFavorite = "Добавить в избранное"
override val removeFavorite = "Убрать из избранного"
override val delete = "Удалить"
override val filterReadingNow = "Сейчас читаю"
override val filterRecentlyAdded = "Недавно добавленные"
override val filterMyLibrary = "Моя библиотека"
override val filterToRead = "К чтению"
override val filterRead = "Прочитанные"
override val scan = "Сканирование"
override val rootFolder = "Корневая папка"
override val choose = "Выбрать"
override val logPrefix = "Лог"
override val bookInfo = "Информация о книге"
override val back = "Назад"
override val backToLibrary = "Вернуться в библиотеку"
override val backToReader = "Вернуться к чтению"
override val titleInfo = "Описание"
override val title = "Название"
override val authors = "Авторы"
override val language = "Язык"
override val date = "Дата"
override val genres = "Жанры"
override val source = "Исходный язык"
override val translator = "Переводчик"
override val notSpecified = "Не указано"
override val statistics = "Статистика"
override val words = "Слова"
override val sections = "Разделы"
override val images = "Иллюстрации"
override val lastReadingPosition = "Последняя позиция чтения"
override val noSavedPosition = "Позиция не сохранена"
override val listItem = "Элемент списка"
override val scrollOffset = "Смещение прокрутки"
override val bookmarks = "Закладки"
override val bookmark = "Закладка"
override val noBookmarks = "Закладок нет"
override val notes = "Заметки"
override val noNotes = "Заметок нет"
override val loading = "Загрузка..."
override val noImage = "Нет изображения"
override val readerTheme = "Тема"
override val readAloud = "Читать вслух"
override val readerMenu = "Меню чтения"
override val info = "Информация..."
override val share = "Поделиться"
override val viewFile = "Показать файл"
override val previousSentence = "Предыдущее предложение"
override val stopReading = "Остановить чтение"
override val startReading = "Начать чтение"
override val nextSentence = "Следующее предложение"
override val readAloudSettings = "Настройки чтения вслух"
override val ttsEngine = "Движок TTS"
override val systemDefault = "Системный по умолчанию"
override val offlineVoice = "Офлайн-голос"
override val autoRussian = "Авто: русский"
override val loadingVoices = "Загружаем голоса..."
override val imageCopied = "Изображение скопировано"
override val copyFailed = "Не удалось скопировать"
override val zoomIn = "Увеличить"
override val zoomOut = "Уменьшить"
override val copyImage = "Скопировать изображение"
override val chooseLibraryFolder = "Выберите папку библиотеки"
override val downloads = "Загрузки"
override val otherFolder = "Другая папка..."
override val play = "Пуск"
override val stop = "Стоп"
override val previous = "Назад"
override val next = "Вперед"
override val readingInBackground = "Чтение в фоне"
override val shareBook = "Поделиться книгой"
override val couldNotLoadAndroidTtsEngines = "Не удалось загрузить Android TTS-движки."
override val couldNotLoadSelectedTtsVoices = "Не удалось загрузить голоса для выбранного TTS-движка."
override val noOfflineVoices = "Выбранный TTS-движок не сообщил об офлайн-голосах."
override fun themeChanged(mode: ThemeMode): String = "$theme: ${mode.localizedName()}"
override fun removed(title: String): String = "Удалено: $title."
override fun restored(title: String): String = "Восстановлено: $title."
override fun couldNotRemove(title: String): String = "Не удалось удалить: $title."
override fun couldNotOpen(displayName: String): String = "Не удалось открыть $displayName."
override fun couldNotReopenLastBook(): String = "Не удалось открыть последнюю книгу."
override fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String =
"Проверено: $scanned, импортировано: $imported, пропущено: $skipped, ошибок: $failed."
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 =
buildString {
append("Проверяем")
currentFile?.takeIf(String::isNotBlank)?.let { append(" $it") }
append(". Найдено поддерживаемых файлов: $scanned")
append(", импортировано: $imported")
append(", пропущено: $skipped")
append(", ошибок: $failed.")
}
override fun importedSkippedFailed(imported: Int, skipped: Int, failed: Int): String =
"Импортировано: $imported, пропущено: $skipped, ошибок: $failed"
override fun scannedBooks(scanned: Int): String = "Просканировано книг: $scanned"
override fun scannedProgress(scanned: Int, total: Int, percent: Int): String =
"Просканировано $scanned из $total, готово $percent%"
override fun noBooksIn(filterLabel: String): String = "Нет книг: ${filterLabel.lowercase()}"
override fun bookMenuFor(title: String): String = "Меню книги: $title"
override fun markedAsRead(title: String?): String =
title?.let { "Отмечено как прочитанное: $it." } ?: "Отмечено как прочитанное."
override fun markedAsUnread(title: String): String = "Отмечено как непрочитанное: $title."
override fun markedToRead(title: String?): String =
title?.let { "Отложено к чтению: $it." } ?: "Отложено к чтению."
override fun markedNotInterested(title: String?): String =
title?.let { "Отмечено как неинтересное: $it." } ?: "Отмечено как неинтересное."
override fun removedMarks(title: String?): String =
title?.let { "Сняты отметки: $it." } ?: "Отметки сняты."
override fun addedToFavorites(title: String?): String =
title?.let { "Добавлено в избранное: $it." } ?: "Добавлено в избранное."
override fun removedFromFavorites(title: String?): String =
title?.let { "Убрано из избранного: $it." } ?: "Убрано из избранного."
override fun couldNotUpdate(title: String): String = "Не удалось обновить: $title."
override fun shareOpened(): String = "Открыто окно отправки."
override fun couldNotShareBook(): String = "Не удалось поделиться книгой."
override fun openedFileLocation(): String = "Расположение файла открыто."
override fun couldNotOpenFileLocation(): String = "Не удалось открыть расположение файла."
override fun allFilesAccessRequired(path: String): String = "Для сканирования $path нужен доступ ко всем файлам."
override fun couldNotRead(name: String): String = "Не удалось прочитать $name"
override fun progressLabel(progress: Double?): String =
progress?.let { "Прогресс ${(it * 100).roundToInt()}%" } ?: "Прогресс не записан"
override fun appLanguageName(language: AppLanguage): String =
when (language) {
AppLanguage.English -> "Английский"
AppLanguage.Russian -> "Русский"
}
override fun sectionFallback(index: Int): String = "Раздел ${index + 1}"
override fun readingStatus(status: BookReadingStatus): String =
when (status) {
BookReadingStatus.NEW -> "Новая"
BookReadingStatus.TO_READ -> "К чтению"
BookReadingStatus.READING -> "Читаю"
BookReadingStatus.READ -> "Прочитана"
BookReadingStatus.NOT_INTERESTED -> "Не интересно"
}
}
internal fun ThemeMode.localizedName(): String =
when (this) {
ThemeMode.LIGHT -> strings.themeLight
ThemeMode.DARK -> strings.themeDark
ThemeMode.SYSTEM -> strings.themeSystem
}

View File

@ -315,7 +315,7 @@ private fun DetailsPane(
}
item {
Text(
strings.sections,
"Sections",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 6.dp, start = 2.dp),
@ -356,7 +356,7 @@ internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit
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 { strings.unknownAuthor },
book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.secondary,
)
@ -374,9 +374,9 @@ internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit
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(strings.genres, book.genres.joinToString().ifBlank { strings.notSpecified })
DetailLine(strings.translator, book.translators.joinToString { it.displayName }.ifBlank { strings.notSpecified })
DetailLine(strings.source, book.sourceLanguage?.uppercase() ?: strings.notSpecified)
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)
}
@ -395,9 +395,9 @@ internal fun MetadataCard(book: Fb2Book) {
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(strings.words, stats.words.formatCompact())
StatBlock(strings.sections, stats.sections.toString())
StatBlock(strings.images, stats.images.toString())
StatBlock("Words", stats.words.formatCompact())
StatBlock("Sections", stats.sections.toString())
StatBlock("Images", stats.images.toString())
}
}
}
@ -586,7 +586,7 @@ private fun BookImage(
contentScale = contentScale,
)
} else {
Text(strings.noImage, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
Text("No image", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
}
}
}
@ -835,7 +835,7 @@ private const val EllipsisPauseAfterMillis = 350L
private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
flatMapIndexed { index, section ->
val fallback = strings.sectionFallback(index)
val fallback = "Section ${index + 1}"
listOf(ChapterEntry(section.title?.ifBlank { null } ?: fallback, section, depth)) +
section.sections.flattenSections(depth + 1)
}

View File

@ -108,7 +108,7 @@ internal fun BookView(
if (status == BookReadingStatus.NEW) markedRead = false
showMessage(successMessage)
} else {
showMessage(strings.couldNotUpdateBook)
showMessage("Could not update book.")
}
}
}
@ -186,16 +186,16 @@ internal fun BookView(
}
},
onMarkAsRead = {
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
setReadingStatus(BookReadingStatus.READ, "Marked as read.")
},
onMarkToRead = {
setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead())
setReadingStatus(BookReadingStatus.TO_READ, "Marked to read.")
},
onNotInterested = {
setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested())
setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.")
},
onClearMarks = {
setReadingStatus(BookReadingStatus.NEW, strings.removedMarks())
setReadingStatus(BookReadingStatus.NEW, "Cleared marks.")
},
readingStatus = libraryItem?.readingStatus,
favorite = libraryItem?.favorite == true,
@ -203,9 +203,9 @@ internal fun BookView(
scope.launch {
if (markLibraryFavorite(fileId, favorite)) {
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites())
showMessage(if (favorite) "Added to favorites." else "Removed from favorites.")
} else {
showMessage(strings.couldNotUpdateBook)
showMessage("Could not update book.")
}
}
},
@ -213,14 +213,14 @@ internal fun BookView(
onShare = {
scope.launch {
val shared = shareLibraryBookFile(fileId)
showMessage(if (shared) strings.shareOpened() else strings.couldNotShareBook())
showMessage(if (shared) "Share opened." else "Could not share book.")
}
},
showViewFileAction = showViewFileAction,
onViewFile = {
scope.launch {
val opened = viewLibraryBookFile(fileId)
showMessage(if (opened) strings.openedFileLocation() else strings.couldNotOpenFileLocation())
showMessage(if (opened) "Opened file location." else "Could not open file location.")
}
},
showReadAloudAction = showReadAloudAction,
@ -337,7 +337,7 @@ private fun CompactReaderTopBar(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
}
Text(
title,
@ -346,20 +346,20 @@ private fun CompactReaderTopBar(
modifier = Modifier.weight(1f),
)
IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme)
Icon(Icons.Filled.Palette, contentDescription = "Theme")
}
if (showReadAloudAction) {
IconButton(onClick = onReadAloud) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = strings.readAloud)
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
}
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = strings.readerMenu)
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(strings.info) },
text = { Text("Info...") },
onClick = {
menuOpen = false
onBookInfo()
@ -367,7 +367,7 @@ private fun CompactReaderTopBar(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(strings.markAsRead) },
text = { Text("Mark as read") },
onClick = {
menuOpen = false
onMarkAsRead()
@ -375,7 +375,7 @@ private fun CompactReaderTopBar(
)
if (readingStatus == BookReadingStatus.TO_READ) {
DropdownMenuItem(
text = { Text(strings.removeToRead) },
text = { Text("Remove to read") },
onClick = {
menuOpen = false
onClearMarks()
@ -383,7 +383,7 @@ private fun CompactReaderTopBar(
)
} else {
DropdownMenuItem(
text = { Text(strings.markToRead) },
text = { Text("Mark to read") },
onClick = {
menuOpen = false
onMarkToRead()
@ -391,21 +391,21 @@ private fun CompactReaderTopBar(
)
}
DropdownMenuItem(
text = { Text(strings.notInterested) },
text = { Text("Not interested") },
onClick = {
menuOpen = false
onNotInterested()
},
)
DropdownMenuItem(
text = { Text(strings.clearMarks) },
text = { Text("Clear marks") },
onClick = {
menuOpen = false
onClearMarks()
},
)
DropdownMenuItem(
text = { Text(if (favorite) strings.removeFavorite else strings.addFavorite) },
text = { Text(if (favorite) "Remove favorite" else "Add favorite") },
onClick = {
menuOpen = false
onFavoriteChange(!favorite)
@ -416,7 +416,7 @@ private fun CompactReaderTopBar(
}
if (showShareAction) {
DropdownMenuItem(
text = { Text(strings.share) },
text = { Text("Share") },
onClick = {
menuOpen = false
onShare()
@ -425,7 +425,7 @@ private fun CompactReaderTopBar(
}
if (showViewFileAction) {
DropdownMenuItem(
text = { Text(strings.viewFile) },
text = { Text("View file") },
onClick = {
menuOpen = false
onViewFile()
@ -434,7 +434,7 @@ private fun CompactReaderTopBar(
}
HorizontalDivider()
DropdownMenuItem(
text = { Text(strings.delete) },
text = { Text("Delete") },
onClick = {
menuOpen = false
onDelete()
@ -466,19 +466,19 @@ private fun ReadAloudPanel(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.Filled.Replay, contentDescription = strings.previousSentence)
Icon(Icons.Filled.Replay, contentDescription = "Previous sentence")
}
IconButton(onClick = onPlayStop) {
Icon(
if (playing) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = if (playing) strings.stopReading else strings.startReading,
contentDescription = if (playing) "Stop reading" else "Start reading",
)
}
IconButton(onClick = onForward) {
Icon(Icons.Filled.FastForward, contentDescription = strings.nextSentence)
Icon(Icons.Filled.FastForward, contentDescription = "Next sentence")
}
IconButton(onClick = onSettings) {
Icon(Icons.Filled.Settings, contentDescription = strings.readAloudSettings)
Icon(Icons.Filled.Settings, contentDescription = "Read aloud settings")
}
}
}
@ -493,24 +493,24 @@ private fun ReadAloudSettingsDialog(
) {
var engineMenuOpen by remember { mutableStateOf(false) }
var voiceMenuOpen by remember { mutableStateOf(false) }
val selectedEngineLabel = state.engines.firstOrNull { it.id == state.selectedEngineId }?.label ?: strings.systemDefault
val selectedEngineLabel = state.engines.firstOrNull { it.id == state.selectedEngineId }?.label ?: "System default"
val selectedVoiceLabel = state.voices.firstOrNull { it.id == state.selectedVoiceId }
?.let { "${it.label} (${it.localeTag})" }
?: strings.autoRussian
?: "Auto Russian"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(strings.readAloud) },
title = { Text("Read aloud") },
text = {
Column {
Text(strings.ttsEngine, style = MaterialTheme.typography.labelMedium)
Text("TTS engine", style = MaterialTheme.typography.labelMedium)
Box {
TextButton(onClick = { engineMenuOpen = true }) {
Text(selectedEngineLabel)
}
DropdownMenu(expanded = engineMenuOpen, onDismissRequest = { engineMenuOpen = false }) {
DropdownMenuItem(
text = { Text(strings.systemDefault) },
text = { Text("System default") },
onClick = {
engineMenuOpen = false
onEngineSelected(null)
@ -528,14 +528,14 @@ private fun ReadAloudSettingsDialog(
}
}
Text(strings.offlineVoice, style = MaterialTheme.typography.labelMedium)
Text("Offline voice", style = MaterialTheme.typography.labelMedium)
Box {
TextButton(onClick = { voiceMenuOpen = true }, enabled = !state.loading && state.voices.isNotEmpty()) {
Text(selectedVoiceLabel)
}
DropdownMenu(expanded = voiceMenuOpen, onDismissRequest = { voiceMenuOpen = false }) {
DropdownMenuItem(
text = { Text(strings.autoRussian) },
text = { Text("Auto Russian") },
onClick = {
voiceMenuOpen = false
onVoiceSelected(null)
@ -556,7 +556,7 @@ private fun ReadAloudSettingsDialog(
if (state.loading) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text(strings.loadingVoices, modifier = Modifier.padding(start = 12.dp))
Text("Loading voices...", modifier = Modifier.padding(start = 12.dp))
}
}
state.message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@ -564,7 +564,7 @@ private fun ReadAloudSettingsDialog(
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(strings.done)
Text("Done")
}
},
)

View File

@ -55,10 +55,10 @@ internal fun ScanScreen(
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(strings.scan) },
title = { Text("Scan") },
navigationIcon = {
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message)) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
}
},
colors = themedTopAppBarColors(),
@ -83,21 +83,21 @@ internal fun ScanScreen(
OutlinedTextField(
value = scanPath,
onValueChange = { scanPath = it },
label = { Text(strings.rootFolder) },
label = { Text("Root folder") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = {
message = strings.scanning
message = "Scanning..."
onStartScan(scanPath)
},
enabled = !busy && scanPath.isNotBlank(),
) {
Icon(Icons.Filled.Scanner, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(strings.scan)
Text("Scan")
}
FilledTonalButton(
onClick = {
@ -109,7 +109,7 @@ internal fun ScanScreen(
) {
Icon(Icons.Filled.FolderOpen, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(strings.choose)
Text("Choose")
}
if (busy) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
@ -126,7 +126,7 @@ internal fun ScanScreen(
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
libraryLogPath()?.let {
Text("${strings.logPrefix}: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
Text("Log: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
}
}
}
@ -136,4 +136,12 @@ internal fun ScanScreen(
}
private fun LibraryScanProgress.toScanMessage(): String =
strings.checkingFile(currentFile, scannedFiles, importedFiles, skippedFiles, failedFiles)
buildString {
append("Checking")
currentFile?.takeIf(String::isNotBlank)?.let { append(" $it") }
append(". Found $scannedFiles supported files")
append(", imported $importedFiles")
append(", skipped $skippedFiles")
append(", failed $failedFiles")
append(".")
}

View File

@ -42,10 +42,10 @@ 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(strings.libraryError, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Library error", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(message, style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = onBack) {
Text(strings.retry)
Text("Retry")
}
}
}
@ -112,4 +112,4 @@ internal fun Int.formatCompact(): String =
if (this >= 10_000) "${(this / 1000.0).roundToInt()}k" else toString()
internal fun Double?.progressLabel(): String =
strings.progressLabel(this)
this?.let { "Progress ${(it * 100).roundToInt()}%" } ?: "Progress not recorded"

View File

@ -9,6 +9,13 @@ internal fun ThemeMode.next(): ThemeMode =
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,

View File

@ -75,7 +75,7 @@ actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = nul
actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
val chooser = JFileChooser(defaultLibraryScanPath())
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = strings.chooseLibraryFolder
chooser.dialogTitle = "Choose library folder"
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile.absolutePath
} else {
@ -373,18 +373,6 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
}
}
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
}
}
actual suspend fun saveAppLocaleTag(localeTag: String?) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(AppLocaleTagFlag, localeTag?.takeIf { it.isNotBlank() })
}
}
actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
@ -601,5 +589,4 @@ private fun runCommand(vararg command: String): String? =
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -1,6 +0,0 @@
package net.sergeych.toread
import java.util.Locale
internal actual fun platformLocaleTags(): List<String> =
listOf(Locale.getDefault().toLanguageTag())

View File

@ -6,7 +6,7 @@ import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = strings.appName,
title = "toread",
) {
App()
}

View File

@ -77,17 +77,6 @@ actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
actual suspend fun loadAppLocaleTag(): String? =
window.localStorage.getItem(AppLocaleStorageKey)?.takeIf { it.isNotBlank() }
actual suspend fun saveAppLocaleTag(localeTag: String?) {
if (localeTag.isNullOrBlank()) {
window.localStorage.removeItem(AppLocaleStorageKey)
} else {
window.localStorage.setItem(AppLocaleStorageKey, localeTag)
}
}
actual suspend fun loadDownloadsWasScanned(): Boolean = false
actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = Unit
@ -110,8 +99,6 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = null
private const val AppLocaleStorageKey = "toread.appLocaleTag"
actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L
val minute = (totalMinutes % 60).toString().padStart(2, '0')

View File

@ -1,6 +0,0 @@
package net.sergeych.toread
import kotlinx.browser.window
internal actual fun platformLocaleTags(): List<String> =
listOf(window.navigator.language)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB