Compare commits

..

2 Commits

Author SHA1 Message Date
128275402e preparing the publication 2026-05-23 17:30:14 +03:00
bad1e89c26 Add app localization 2026-05-23 16:45:42 +03:00
35 changed files with 1049 additions and 385 deletions

View File

@ -2,6 +2,10 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val appVersionName = "1.0"
val appVersionCode = 1
val appVersionDisplay = "$appVersionName.$appVersionCode"
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
@ -10,6 +14,38 @@ plugins {
alias(libs.plugins.composeHotReload) 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 { kotlin {
jvmToolchain(17) jvmToolchain(17)
@ -33,6 +69,9 @@ kotlin {
} }
sourceSets { sourceSets {
commonMain {
kotlin.srcDir(generateAppVersionConstants)
}
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -69,8 +108,8 @@ android {
applicationId = "net.sergeych.toread" applicationId = "net.sergeych.toread"
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = appVersionCode
versionName = "1.0" versionName = appVersionName
} }
packaging { packaging {
resources { resources {
@ -99,8 +138,8 @@ compose.desktop {
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "net.sergeych.toread" packageName = "To Read"
packageVersion = "1.0.0" packageVersion = appVersionDisplay
macOS { macOS {
iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns")) iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns"))
@ -111,6 +150,7 @@ compose.desktop {
menu = true menu = true
} }
linux { linux {
packageName = "to-read"
iconFile.set(project.file("src/jvmMain/resources/icons/icon.png")) iconFile.set(project.file("src/jvmMain/resources/icons/icon.png"))
shortcut = true shortcut = true
} }

Binary file not shown.

View File

@ -0,0 +1,37 @@
{
"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) scanLibraryContentTree(Uri.parse(path), ::emitProgress)
} else { } else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) { if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.") error(strings.allFilesAccessRequired(path))
} }
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) { 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) putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
val chooser = Intent.createChooser(intent, "Share book").apply { val chooser = Intent.createChooser(intent, strings.shareBook).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
@ -438,6 +438,18 @@ 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) { actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull() db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
@ -527,7 +539,7 @@ private fun scanLibraryContentTree(
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name)) onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
val result = runCatching { val result = runCatching {
val bytes = appContext.contentResolver.openInputStream(document.uri)?.use { it.readBytes() } val bytes = appContext.contentResolver.openInputStream(document.uri)?.use { it.readBytes() }
?: error("Could not read ${document.name}") ?: error(strings.couldNotRead(document.name))
scanner.importExternalFile( scanner.importExternalFile(
bytes = bytes, bytes = bytes,
displayName = document.name, displayName = document.name,
@ -781,4 +793,5 @@ private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""")
private const val ActiveReadingFileIdFlag = "active_reading_file_id" private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned" private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -0,0 +1,6 @@
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?>) { private fun showDirectoryChoice(result: CompletableDeferred<String?>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("Choose library folder") .setTitle(strings.chooseLibraryFolder)
.setItems(arrayOf("Downloads", "Other folder...")) { _, which -> .setItems(arrayOf(strings.downloads, strings.otherFolder)) { _, which ->
when (which) { when (which) {
0 -> chooseDownloadsDirectory(result) 0 -> chooseDownloadsDirectory(result)
else -> launchSystemDirectoryPicker(result) else -> launchSystemDirectoryPicker(result)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -145,6 +145,10 @@ expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: 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 loadDownloadsWasScanned(): Boolean
expect suspend fun saveDownloadsWasScanned(scanned: Boolean) expect suspend fun saveDownloadsWasScanned(scanned: Boolean)

View File

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

View File

@ -0,0 +1,429 @@
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 { item {
Text( Text(
"Sections", strings.sections,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 6.dp, start = 2.dp), 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)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text( Text(
book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" }, book.authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
@ -374,9 +374,9 @@ internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit
internal fun MetadataCard(book: Fb2Book) { internal fun MetadataCard(book: Fb2Book) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) { Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
DetailLine("Genres", book.genres.joinToString().ifBlank { "Not specified" }) DetailLine(strings.genres, book.genres.joinToString().ifBlank { strings.notSpecified })
DetailLine("Translator", book.translators.joinToString { it.displayName }.ifBlank { "Not specified" }) DetailLine(strings.translator, book.translators.joinToString { it.displayName }.ifBlank { strings.notSpecified })
DetailLine("Source", book.sourceLanguage?.uppercase() ?: "Not specified") DetailLine(strings.source, book.sourceLanguage?.uppercase() ?: strings.notSpecified)
if (book.annotation.isNullOrBlank().not()) { if (book.annotation.isNullOrBlank().not()) {
Text(book.annotation.orEmpty(), style = MaterialTheme.typography.bodyMedium, lineHeight = 20.sp) 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) { internal fun StatsCard(stats: BookStats) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) { Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
StatBlock("Words", stats.words.formatCompact()) StatBlock(strings.words, stats.words.formatCompact())
StatBlock("Sections", stats.sections.toString()) StatBlock(strings.sections, stats.sections.toString())
StatBlock("Images", stats.images.toString()) StatBlock(strings.images, stats.images.toString())
} }
} }
} }
@ -586,7 +586,7 @@ private fun BookImage(
contentScale = contentScale, contentScale = contentScale,
) )
} else { } else {
Text("No image", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline) Text(strings.noImage, 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> = private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
flatMapIndexed { index, section -> flatMapIndexed { index, section ->
val fallback = "Section ${index + 1}" val fallback = strings.sectionFallback(index)
listOf(ChapterEntry(section.title?.ifBlank { null } ?: fallback, section, depth)) + listOf(ChapterEntry(section.title?.ifBlank { null } ?: fallback, section, depth)) +
section.sections.flattenSections(depth + 1) section.sections.flattenSections(depth + 1)
} }

View File

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

View File

@ -55,10 +55,10 @@ internal fun ScanScreen(
Scaffold( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { Text("Scan") }, title = { Text(strings.scan) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message)) }) { IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message)) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary)
} }
}, },
colors = themedTopAppBarColors(), colors = themedTopAppBarColors(),
@ -83,21 +83,21 @@ internal fun ScanScreen(
OutlinedTextField( OutlinedTextField(
value = scanPath, value = scanPath,
onValueChange = { scanPath = it }, onValueChange = { scanPath = it },
label = { Text("Root folder") }, label = { Text(strings.rootFolder) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Button( Button(
onClick = { onClick = {
message = "Scanning..." message = strings.scanning
onStartScan(scanPath) onStartScan(scanPath)
}, },
enabled = !busy && scanPath.isNotBlank(), enabled = !busy && scanPath.isNotBlank(),
) { ) {
Icon(Icons.Filled.Scanner, contentDescription = null) Icon(Icons.Filled.Scanner, contentDescription = null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Scan") Text(strings.scan)
} }
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
@ -109,7 +109,7 @@ internal fun ScanScreen(
) { ) {
Icon(Icons.Filled.FolderOpen, contentDescription = null) Icon(Icons.Filled.FolderOpen, contentDescription = null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Choose") Text(strings.choose)
} }
if (busy) { if (busy) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp) 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) Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
} }
libraryLogPath()?.let { libraryLogPath()?.let {
Text("Log: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) Text("${strings.logPrefix}: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
} }
} }
} }
@ -136,12 +136,4 @@ internal fun ScanScreen(
} }
private fun LibraryScanProgress.toScanMessage(): String = private fun LibraryScanProgress.toScanMessage(): String =
buildString { strings.checkingFile(currentFile, scannedFiles, importedFiles, skippedFiles, failedFiles)
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) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors()) { Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Library error", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text(strings.libraryError, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(message, style = MaterialTheme.typography.bodyMedium) Text(message, style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = onBack) { TextButton(onClick = onBack) {
Text("Retry") Text(strings.retry)
} }
} }
} }
@ -112,4 +112,4 @@ internal fun Int.formatCompact(): String =
if (this >= 10_000) "${(this / 1000.0).roundToInt()}k" else toString() if (this >= 10_000) "${(this / 1000.0).roundToInt()}k" else toString()
internal fun Double?.progressLabel(): String = internal fun Double?.progressLabel(): String =
this?.let { "Progress ${(it * 100).roundToInt()}%" } ?: "Progress not recorded" strings.progressLabel(this)

View File

@ -9,13 +9,6 @@ internal fun ThemeMode.next(): ThemeMode =
ThemeMode.DARK -> ThemeMode.SYSTEM 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( internal fun lightReaderColorScheme() = androidx.compose.material3.lightColorScheme(
primary = Color(0xFF425D56), primary = Color(0xFF425D56),
onPrimary = Color.White, onPrimary = Color.White,

View File

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

View File

@ -0,0 +1,6 @@
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 { fun main() = application {
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "toread", title = strings.appName,
) { ) {
App() App()
} }

View File

@ -77,6 +77,17 @@ actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit 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 loadDownloadsWasScanned(): Boolean = false
actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = Unit actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = Unit
@ -99,6 +110,8 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = null actual fun libraryLogPath(): String? = null
private const val AppLocaleStorageKey = "toread.appLocaleTag"
actual fun formatLibraryLastReadTime(millis: Long): String { actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L val totalMinutes = millis / 60_000L
val minute = (totalMinutes % 60).toString().padStart(2, '0') val minute = (totalMinutes % 60).toString().padStart(2, '0')

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB