From bad1e89c2607afd424ab1aa67fcd049a32848b1e Mon Sep 17 00:00:00 2001 From: sergeych Date: Sat, 23 May 2026 16:45:42 +0300 Subject: [PATCH] Add app localization --- composeApp/build.gradle.kts | 3 +- .../sergeych/toread/BookPlatform.android.kt | 6 +- .../sergeych/toread/Localization.android.kt | 6 + .../net/sergeych/toread/MainActivity.kt | 4 +- .../toread/ReadAloudPlatform.android.kt | 18 +- .../src/androidMain/res/values-ru/strings.xml | 3 + .../src/androidMain/res/values/strings.xml | 4 +- .../kotlin/net/sergeych/toread/App.kt | 16 +- .../kotlin/net/sergeych/toread/AppState.kt | 12 +- .../net/sergeych/toread/BookInfoScreen.kt | 48 +- .../kotlin/net/sergeych/toread/ImageViewer.kt | 12 +- .../net/sergeych/toread/LibraryDelete.kt | 2 +- .../net/sergeych/toread/LibraryScreen.kt | 502 ++++++++++-------- .../net/sergeych/toread/Localization.kt | 403 ++++++++++++++ .../net/sergeych/toread/ReaderContent.kt | 20 +- .../net/sergeych/toread/ReaderScreen.kt | 72 +-- .../kotlin/net/sergeych/toread/ScanScreen.kt | 24 +- .../kotlin/net/sergeych/toread/SharedUi.kt | 6 +- .../kotlin/net/sergeych/toread/Theme.kt | 7 - .../net/sergeych/toread/BookPlatform.jvm.kt | 2 +- .../net/sergeych/toread/Localization.jvm.kt | 6 + .../kotlin/net/sergeych/toread/main.kt | 4 +- .../net/sergeych/toread/Localization.web.kt | 6 + 23 files changed, 815 insertions(+), 371 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/net/sergeych/toread/Localization.android.kt create mode 100644 composeApp/src/androidMain/res/values-ru/strings.xml create mode 100644 composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt create mode 100644 composeApp/src/jvmMain/kotlin/net/sergeych/toread/Localization.jvm.kt create mode 100644 composeApp/src/webMain/kotlin/net/sergeych/toread/Localization.web.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1ee37bb..b609148 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -99,7 +99,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "net.sergeych.toread" + packageName = "To Read" packageVersion = "1.0.0" macOS { @@ -111,6 +111,7 @@ compose.desktop { menu = true } linux { + packageName = "to-read" iconFile.set(project.file("src/jvmMain/resources/icons/icon.png")) shortcut = true } diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index c0f048c..f3a019e 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -198,7 +198,7 @@ actual suspend fun scanLibrarySubtree( scanLibraryContentTree(Uri.parse(path), ::emitProgress) } else { if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) { - error("All files access is required to scan $path.") + error(strings.allFilesAccessRequired(path)) } openLibraryDatabase().useLibrary { db -> val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) { @@ -363,7 +363,7 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(D putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - val chooser = Intent.createChooser(intent, "Share book").apply { + val chooser = Intent.createChooser(intent, strings.shareBook).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -527,7 +527,7 @@ private fun scanLibraryContentTree( onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name)) val result = runCatching { val bytes = appContext.contentResolver.openInputStream(document.uri)?.use { it.readBytes() } - ?: error("Could not read ${document.name}") + ?: error(strings.couldNotRead(document.name)) scanner.importExternalFile( bytes = bytes, displayName = document.name, diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/Localization.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/Localization.android.kt new file mode 100644 index 0000000..154149a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/Localization.android.kt @@ -0,0 +1,6 @@ +package net.sergeych.toread + +import java.util.Locale + +internal actual fun platformLocaleTags(): List = + listOf(Locale.getDefault().toLanguageTag()) diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt index 494184c..d7bc604 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt @@ -82,8 +82,8 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { private fun showDirectoryChoice(result: CompletableDeferred) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { AlertDialog.Builder(this) - .setTitle("Choose library folder") - .setItems(arrayOf("Downloads", "Other folder...")) { _, which -> + .setTitle(strings.chooseLibraryFolder) + .setItems(arrayOf(strings.downloads, strings.otherFolder)) { _, which -> when (which) { 0 -> chooseDownloadsDirectory(result) else -> launchSystemDirectoryPicker(result) diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt index 89b8c68..51edc4f 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt @@ -146,7 +146,7 @@ private object AndroidReadAloudEngine { if (status != TextToSpeech.SUCCESS) { mutableSettingsState.value = mutableSettingsState.value.copy( loading = false, - message = "Could not load Android TTS engines.", + message = strings.couldNotLoadAndroidTtsEngines, ) probe?.shutdown() if (engineProbe === probe) engineProbe = null @@ -319,7 +319,7 @@ private object AndroidReadAloudEngine { if (status != TextToSpeech.SUCCESS) { mutableSettingsState.value = mutableSettingsState.value.copy( loading = false, - message = "Could not load voices for the selected TTS engine.", + message = strings.couldNotLoadSelectedTtsVoices, ) probe?.shutdown() if (voiceProbe === probe) voiceProbe = null @@ -349,7 +349,7 @@ private object AndroidReadAloudEngine { voices = voices, selectedVoiceId = savedVoice, 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) { context.readAloudPrefs().edit().remove(SelectedVoiceKey).apply() @@ -443,20 +443,20 @@ class ReadAloudService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val playStopAction = if (state.playing) ActionStop else ActionPlay - val playStopTitle = if (state.playing) "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 return NotificationCompat.Builder(this, ChannelId) .setSmallIcon(R.drawable.ic_launcher_background) - .setContentTitle("Read aloud") - .setContentText("Reading in the background") + .setContentTitle(strings.readAloud) + .setContentText(strings.readingInBackground) .setContentIntent(contentIntent) .setOngoing(state.playing) .setOnlyAlertOnce(true) .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(android.R.drawable.ic_media_next, "Next", serviceIntent(ActionForward)) + .addAction(android.R.drawable.ic_media_next, strings.next, serviceIntent(ActionForward)) .build() } @@ -481,7 +481,7 @@ class ReadAloudService : Service() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val channel = NotificationChannel( ChannelId, - "Read aloud", + strings.readAloud, NotificationManager.IMPORTANCE_LOW, ) getSystemService(NotificationManager::class.java).createNotificationChannel(channel) diff --git a/composeApp/src/androidMain/res/values-ru/strings.xml b/composeApp/src/androidMain/res/values-ru/strings.xml new file mode 100644 index 0000000..ea7e66e --- /dev/null +++ b/composeApp/src/androidMain/res/values-ru/strings.xml @@ -0,0 +1,3 @@ + + Читать + diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 9c98f2b..2a006f3 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -1,3 +1,3 @@ - toread - \ No newline at end of file + To Read + diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 9c2ea56..c076bef 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -93,7 +93,7 @@ fun App() { onThemeToggle = { val next = themeMode.next() themeMode = next - showToast("Theme: ${next.displayName}") + showToast(strings.themeChanged(next)) scope.launch { saveThemeMode(next) } @@ -237,15 +237,15 @@ private fun BookReaderApp( } onShowToast( - "Removed ${request.title}.", - "Undo", + strings.removed(request.title), + strings.undo, { if (pendingDelete?.id == deleteId) { pendingDeleteJob?.cancel() pendingDeleteJob = null pendingDelete = null restore() - onShowToast("Restored ${request.title}.", null, null, DefaultToastDurationMillis) + onShowToast(strings.restored(request.title), null, null, DefaultToastDurationMillis) } }, DeleteUndoDurationMillis, @@ -303,9 +303,9 @@ private fun BookReaderApp( if (isDownloadsScanPath(path)) { 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 activeScan = null @@ -329,7 +329,7 @@ private fun BookReaderApp( } when (val current = state) { - AppState.LoadingStartup -> LoadingScreen("Opening book") + AppState.LoadingStartup -> LoadingScreen(strings.loadingOpeningBook) is AppState.Library -> LibraryScreen( state = current, activeScan = activeScan, @@ -345,7 +345,7 @@ private fun BookReaderApp( onStateChange = { state = it }, onStartScan = { path -> startScan(path) - state = AppState.Library(current.items, path, "Scanning...") + state = AppState.Library(current.items, path, strings.scanning) }, ) is AppState.Reader -> BookView( diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt index 9665adc..0d5a318 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt @@ -40,7 +40,7 @@ internal suspend fun loadStartupState(): AppState { val scanPath = try { defaultLibraryScanPath().orEmpty() } catch (t: Throwable) { - return AppState.Error(t.message ?: "Could not open library.") + return AppState.Error(t.message ?: strings.couldNotOpenLibrary) } loadPlatformOpenBookRequest()?.let { request -> return runCatching { @@ -50,8 +50,8 @@ internal suspend fun loadStartupState(): AppState { libraryItems = emptyList(), scanPath = scanPath, ) - }.getOrElse { - AppState.Library(emptyList(), scanPath, it.message ?: "Could not open ${request.displayName}.") + }.getOrElse { + AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName)) } } val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath) @@ -61,7 +61,7 @@ internal suspend fun loadStartupState(): AppState { return AppState.Library(emptyList(), scanPath) } return runCatching { - val bytes = openLibraryBook(activeFileId) ?: error("Book file is not available.") + val bytes = openLibraryBook(activeFileId) ?: error(strings.bookFileNotAvailable) AppState.Reader( fileId = activeFileId, book = Fb2Format.parse(bytes, item.storageUri ?: item.title), @@ -70,7 +70,7 @@ internal suspend fun loadStartupState(): AppState { ) }.getOrElse { saveActiveReadingFileId(null) - AppState.Library(emptyList(), scanPath, it.message ?: "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, ) }.getOrElse { - AppState.Error(it.message ?: "Could not open library.") + AppState.Error(it.message ?: strings.couldNotOpenLibrary) } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt index 45753b1..b3436b3 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt @@ -51,10 +51,10 @@ internal fun BookInfoScreen( Scaffold( topBar = { CenterAlignedTopAppBar( - title = { Text("Book info") }, + title = { Text(strings.bookInfo) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToReader) } }, colors = themedTopAppBarColors(), @@ -70,46 +70,46 @@ internal fun BookInfoScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - InfoSection("Title Info") { + InfoSection(strings.titleInfo) { CoverAndTitle(book, onImageOpen = onImageOpen) - DetailLine("Title", book.title) - DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" }) - DetailLine("Language", book.language?.uppercase() ?: "Not specified") - DetailLine("Date", book.date ?: "Not specified") - DetailLine("Genres", book.genres.joinToString().ifBlank { "Not specified" }) - DetailLine("Source", book.sourceLanguage?.uppercase() ?: "Not specified") + DetailLine(strings.title, book.title) + DetailLine(strings.authors, book.authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor }) + DetailLine(strings.language, book.language?.uppercase() ?: strings.notSpecified) + DetailLine(strings.date, book.date ?: strings.notSpecified) + DetailLine(strings.genres, book.genres.joinToString().ifBlank { strings.notSpecified }) + DetailLine(strings.source, book.sourceLanguage?.uppercase() ?: strings.notSpecified) if (book.annotation.isNullOrBlank().not()) { Text(book.annotation.orEmpty(), style = MaterialTheme.typography.bodyMedium, lineHeight = 20.sp) } } } item { - InfoSection("Statistics") { + InfoSection(strings.statistics) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - StatBlock("Words", stats.words.formatCompact()) - StatBlock("Sections", stats.sections.toString()) - StatBlock("Images", stats.images.toString()) + StatBlock(strings.words, stats.words.formatCompact()) + StatBlock(strings.sections, stats.sections.toString()) + StatBlock(strings.images, stats.images.toString()) } } } item { - InfoSection("Last Reading Position") { + InfoSection(strings.lastReadingPosition) { val position = extras?.lastReadingPosition if (position == null) { - Text("No saved position", style = MaterialTheme.typography.bodyMedium) + Text(strings.noSavedPosition, style = MaterialTheme.typography.bodyMedium) } else { - DetailLine("List item", position.itemIndex.toString()) - DetailLine("Scroll offset", "${position.scrollOffset}px") + DetailLine(strings.listItem, position.itemIndex.toString()) + DetailLine(strings.scrollOffset, "${position.scrollOffset}px") } } } item { - InfoSection("Bookmarks") { + InfoSection(strings.bookmarks) { val bookmarks = extras?.bookmarks.orEmpty() if (extras == null) { - Text("Loading...", style = MaterialTheme.typography.bodyMedium) + Text(strings.loading, style = MaterialTheme.typography.bodyMedium) } else if (bookmarks.isEmpty()) { - Text("No bookmarks", style = MaterialTheme.typography.bodyMedium) + Text(strings.noBookmarks, style = MaterialTheme.typography.bodyMedium) } else { bookmarks.forEach { bookmark -> BookmarkInfoLine(bookmark) @@ -118,12 +118,12 @@ internal fun BookInfoScreen( } } item { - InfoSection("Notes") { + InfoSection(strings.notes) { val notes = extras?.notes.orEmpty() if (extras == null) { - Text("Loading...", style = MaterialTheme.typography.bodyMedium) + Text(strings.loading, style = MaterialTheme.typography.bodyMedium) } else if (notes.isEmpty()) { - Text("No notes", style = MaterialTheme.typography.bodyMedium) + Text(strings.noNotes, style = MaterialTheme.typography.bodyMedium) } else { notes.forEach { note -> NoteInfoLine(note) @@ -148,7 +148,7 @@ private fun InfoSection(title: String, content: @Composable ColumnScope.() -> Un @Composable private fun BookmarkInfoLine(bookmark: BookmarkInfo) { Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { - Text(bookmark.title?.ifBlank { null } ?: "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 { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, maxLines = 3) } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ImageViewer.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ImageViewer.kt index b12c669..d41e437 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ImageViewer.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ImageViewer.kt @@ -88,9 +88,9 @@ internal fun ImageViewer( fun copyImage() { scope.launch { val message = if (copyImageToClipboard(image.bytes, image.mimeType, image.title)) { - "Image copied" + strings.imageCopied } else { - "Copy failed" + strings.copyFailed } snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) } @@ -110,7 +110,7 @@ internal fun ImageViewer( verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.back) } Text( image.title, @@ -119,15 +119,15 @@ internal fun ImageViewer( modifier = Modifier.weight(1f), ) IconButton(onClick = { setScale(scale * 1.25f) }) { - Icon(Icons.Filled.ZoomIn, contentDescription = "Zoom in") + Icon(Icons.Filled.ZoomIn, contentDescription = strings.zoomIn) } IconButton(onClick = { setScale(scale / 1.25f) }, enabled = scale > MinImageScale) { - Icon(Icons.Filled.ZoomOut, contentDescription = "Zoom out") + Icon(Icons.Filled.ZoomOut, contentDescription = strings.zoomOut) } IconButton( onClick = ::copyImage, ) { - Icon(Icons.Filled.ContentCopy, contentDescription = "Copy image") + Icon(Icons.Filled.ContentCopy, contentDescription = strings.copyImage) } } } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt index b30be4c..9bcca09 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt @@ -14,6 +14,6 @@ internal suspend fun deleteLibraryBook(fileId: String, title: String): LibraryDe val deleted = runCatching { deleteLibraryItem(fileId) }.getOrDefault(false) return LibraryDeleteResult( deleted = deleted, - message = if (deleted) "Removed $title." else "Could not remove $title.", + message = if (deleted) strings.removed(title) else strings.couldNotRemove(title), ) } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 0ca42e4..5b1ebae 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -1,5 +1,7 @@ package net.sergeych.toread +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,7 +24,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Favorite @@ -54,6 +55,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key @@ -63,9 +65,9 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import net.sergeych.toread.fb2.Fb2Format import net.sergeych.toread.storage.BookReadingStatus @@ -89,6 +91,7 @@ internal fun LibraryScreen( ) -> Unit, ) { val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current var busy by remember { mutableStateOf(false) } var message by remember(state.message) { mutableStateOf(state.message) } var items by remember(state.items) { mutableStateOf(state.items) } @@ -102,20 +105,19 @@ internal fun LibraryScreen( var autoScanSettingLoaded by remember { mutableStateOf(false) } var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } + var searchFocused by remember { mutableStateOf(false) } var searchResults by remember { mutableStateOf>(emptyList()) } var searching by remember { mutableStateOf(false) } - var readingNowCollapsed by remember { mutableStateOf(false) } - var favoritesCollapsed by remember { mutableStateOf(false) } - var toReadCollapsed by remember { mutableStateOf(false) } - var readCollapsed by remember { mutableStateOf(false) } - var recentlyAddedCollapsed by remember { mutableStateOf(false) } - var myLibraryCollapsed by remember { mutableStateOf(false) } - var notInterestedCollapsed by remember { mutableStateOf(false) } + var selectedFilter by remember(state.scanPath) { mutableStateOf(LibraryFilter.ReadingNow) } + var filterChosenByUser by remember(state.scanPath) { mutableStateOf(false) } val coverCache = remember { mutableStateMapOf() } val searchActive = searchText.isNotBlank() val libraryItems = items.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) { if (loadingPage) return @@ -143,7 +145,7 @@ internal fun LibraryScreen( nextOffset = offset + page.size endReached = page.size < limit } catch (t: Throwable) { - message = t.message ?: "Could not load library." + message = t.message ?: strings.couldNotLoadLibrary endReached = true } finally { loadingPage = false @@ -189,7 +191,7 @@ internal fun LibraryScreen( loadRecentlyAdded() } } else { - message = "Could not update ${item.title}." + message = strings.couldNotUpdate(item.title) } } @@ -213,7 +215,7 @@ internal fun LibraryScreen( loadRecentlyAdded() } } else { - message = "Could not update ${item.title}." + message = strings.couldNotUpdate(item.title) } } @@ -221,14 +223,14 @@ internal fun LibraryScreen( settingsMenuOpen = false scope.launch { busy = true - message = "Rescanning library..." + message = strings.rescanningLibrary try { val report = rescanAllLibraryBooks() refresh( - "Rescanned ${report.scannedFiles}, updated ${report.updatedFiles}, failed ${report.failedFiles}.", + strings.rescanReport(report.scannedFiles, report.updatedFiles, report.failedFiles), ) } catch (t: Throwable) { - message = t.message ?: "Library rescan failed." + message = t.message ?: strings.libraryRescanFailed } finally { busy = false } @@ -241,6 +243,12 @@ internal fun LibraryScreen( searching = false } + fun closeSearch() { + clearSearch() + focusManager.clearFocus() + searchFocused = false + } + LaunchedEffect(state.scanPath, state.message) { if (items.isEmpty() && !endReached) { loadPage(reset = true) @@ -267,12 +275,23 @@ internal fun LibraryScreen( .onFailure { if (searchText == query) { searchResults = emptyList() - message = it.message ?: "Search failed." + message = it.message ?: strings.searchFailed } } if (searchText == query) searching = false } + LaunchedEffect(searchFocused) { + if (searchFocused) settingsMenuOpen = false + } + + LaunchedEffect(searchActive, loadingPage, endReached, libraryItems, recentlyAdded) { + val libraryDataLoaded = endReached || libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty() + if (!filterChosenByUser && !searchActive && !loadingPage && libraryDataLoaded) { + selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded) + } + } + LaunchedEffect(Unit) { autoScanDownloads = loadScanDownloadsAutomatically() autoScanSettingLoaded = true @@ -290,7 +309,7 @@ internal fun LibraryScreen( if (loadDownloadsWasScanned()) { downloadsScanPath()?.let { path -> backgroundDownloadsRescanStarted = true - message = "Scanning Downloads..." + message = strings.scanningDownloads onStartScan(path) } } @@ -315,20 +334,39 @@ internal fun LibraryScreen( topBar = { TopAppBar( title = { - LibrarySearchField( - value = searchText, - onValueChange = { searchText = it }, - onClear = ::clearSearch, - modifier = Modifier.fillMaxWidth(), - ) + Row( + modifier = Modifier.fillMaxWidth().animateContentSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility(visible = !searchFocused) { + LibraryFilterSelect( + selected = selectedFilter, + onSelected = { + filterChosenByUser = true + selectedFilter = it + }, + ) + } + LibrarySearchField( + value = searchText, + focused = searchFocused, + onValueChange = { searchText = it }, + onClear = ::clearSearch, + onClose = ::closeSearch, + onFocusChanged = { searchFocused = it }, + modifier = Modifier.weight(1f), + ) + } }, colors = themedTopAppBarColors(), actions = { - Box { - IconButton(onClick = { settingsMenuOpen = true }) { - Icon(Icons.Filled.MoreVert, contentDescription = "Library options") - } - DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) { + AnimatedVisibility(visible = !searchFocused) { + Box { + IconButton(onClick = { settingsMenuOpen = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = strings.libraryOptions) + } + DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) { // DropdownMenuItem( // text = { Text("Rescan all library") }, // enabled = !busy && activeScan == null, @@ -339,7 +377,7 @@ internal fun LibraryScreen( leadingIcon = { Checkbox(checked = autoScanDownloads, onCheckedChange = null) }, - text = { Text("Scan Downloads automatically") }, + text = { Text(strings.scanDownloadsAutomatically) }, onClick = { val next = !autoScanDownloads autoScanDownloads = next @@ -347,6 +385,7 @@ internal fun LibraryScreen( scope.launch { saveScanDownloadsAutomatically(next) } }, ) + } } } }, @@ -354,7 +393,7 @@ internal fun LibraryScreen( }, floatingActionButton = { FloatingActionButton(onClick = onNavigateToScan) { - Icon(Icons.Filled.Add, contentDescription = "Scan folder") + Icon(Icons.Filled.Add, contentDescription = strings.scanFolder) } }, ) { @@ -363,11 +402,21 @@ internal fun LibraryScreen( .fillMaxSize() .padding(it) .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && searchText.isNotBlank()) { - clearSearch() - true - } else { - false + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when { + event.key == Key.Escape && searchText.isNotBlank() -> { + clearSearch() + true + } + event.key == Key.Escape && searchFocused -> { + closeSearch() + true + } + event.key.isEnterKey() && searchFocused && searchText.isBlank() -> { + closeSearch() + true + } + else -> false } } .background(readerBackground()), @@ -377,11 +426,14 @@ internal fun LibraryScreen( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - } else if (visibleItems.isEmpty()) { + } else if (visibleItems.isEmpty() && !canLoadMore) { if (searchActive) { EmptySearchPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp)) } 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 { LazyColumn( @@ -395,7 +447,7 @@ internal fun LibraryScreen( busy = true try { 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) var readerLibraryItems = visibleItems refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem -> @@ -422,7 +474,7 @@ internal fun LibraryScreen( message = message, ) }.getOrElse { - AppState.Library(visibleItems, state.scanPath, it.message ?: "Could not open book.") + AppState.Library(visibleItems, state.scanPath, it.message ?: strings.couldNotOpenBook) } onStateChange(next) } finally { @@ -434,7 +486,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - applyReadingStatus(item, BookReadingStatus.READ, "Marked ${item.title} as read.") + applyReadingStatus(item, BookReadingStatus.READ, strings.markedAsRead(item.title)) } finally { busy = false } @@ -444,7 +496,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - applyReadingStatus(item, BookReadingStatus.NEW, "Marked ${item.title} as unread.") + applyReadingStatus(item, BookReadingStatus.NEW, strings.markedAsUnread(item.title)) } finally { busy = false } @@ -454,7 +506,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - applyReadingStatus(item, BookReadingStatus.NEW, "Removed marks from ${item.title}.") + applyReadingStatus(item, BookReadingStatus.NEW, strings.removedMarks(item.title)) } finally { busy = false } @@ -464,7 +516,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - applyReadingStatus(item, BookReadingStatus.TO_READ, "Marked ${item.title} to read.") + applyReadingStatus(item, BookReadingStatus.TO_READ, strings.markedToRead(item.title)) } finally { busy = false } @@ -474,7 +526,7 @@ internal fun LibraryScreen( scope.launch { busy = true try { - applyReadingStatus(item, BookReadingStatus.NOT_INTERESTED, "Marked ${item.title} as not interested.") + applyReadingStatus(item, BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested(item.title)) } finally { busy = false } @@ -484,7 +536,7 @@ internal fun LibraryScreen( scope.launch { busy = true 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) } finally { busy = false @@ -521,8 +573,8 @@ internal fun LibraryScreen( }, ) - fun LazyListScope.libraryRows(sectionKey: String, sectionItems: List) { - itemsIndexed(sectionItems, key = { _, item -> "$sectionKey-${item.fileId}" }) { _, item -> + fun LazyListScope.libraryRows(rowItems: List) { + itemsIndexed(rowItems, key = { _, item -> item.fileId }) { _, item -> LibraryRow( item = item, coverCache = coverCache, @@ -532,98 +584,9 @@ internal fun LibraryScreen( } } - if (searchActive) { - libraryRows("search", visibleItems) - } else { - val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING } - val favorites = visibleItems.filter { it.favorite } - val toRead = visibleItems.filter { it.readingStatus == BookReadingStatus.TO_READ } - val read = visibleItems.filter { it.readingStatus == BookReadingStatus.READ } - val recentlyAdded = recentlyAddedItems.filter { - it.fileId !in hiddenFileIds && it.readingStatus == BookReadingStatus.NEW - } - val recentlyAddedIds = recentlyAdded.mapTo(mutableSetOf()) { it.fileId } - val myLibrary = visibleItems.filter { - it.fileId !in recentlyAddedIds && - it.readingStatus != BookReadingStatus.READING && - it.readingStatus != BookReadingStatus.TO_READ && - it.readingStatus != BookReadingStatus.READ && - it.readingStatus != BookReadingStatus.NOT_INTERESTED - } - val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED } + libraryRows(visibleItems) - librarySection( - key = "reading", - title = "reading now", - itemCount = readingNow.size, - displayCount = readingNow.size.takeIf { endReached }, - collapsed = readingNowCollapsed, - onCollapsedChange = { readingNowCollapsed = it }, - ) { - libraryRows("reading", readingNow) - } - librarySection( - key = "favorites", - title = "favorites", - itemCount = favorites.size, - displayCount = favorites.size.takeIf { endReached }, - collapsed = favoritesCollapsed, - onCollapsedChange = { favoritesCollapsed = it }, - ) { - libraryRows("favorites", favorites) - } - librarySection( - key = "to-read", - title = "to read", - itemCount = toRead.size, - displayCount = toRead.size.takeIf { endReached }, - collapsed = toReadCollapsed, - onCollapsedChange = { toReadCollapsed = it }, - ) { - libraryRows("to-read", toRead) - } - librarySection( - key = "recently-added", - title = "recently added", - itemCount = recentlyAdded.size, - displayCount = null, - collapsed = recentlyAddedCollapsed, - onCollapsedChange = { recentlyAddedCollapsed = it }, - ) { - libraryRows("recently-added", recentlyAdded) - } - librarySection( - key = "library", - title = "my library", - itemCount = myLibrary.size, - displayCount = myLibrary.size.takeIf { endReached }, - collapsed = myLibraryCollapsed, - onCollapsedChange = { myLibraryCollapsed = it }, - ) { - libraryRows("library", myLibrary) - } - librarySection( - key = "read", - title = "read", - itemCount = read.size, - displayCount = read.size.takeIf { endReached }, - collapsed = readCollapsed, - onCollapsedChange = { readCollapsed = it }, - ) { - libraryRows("read", read) - } - librarySection( - key = "not-interested", - title = "not interested", - itemCount = notInterested.size, - displayCount = notInterested.size.takeIf { endReached }, - collapsed = notInterestedCollapsed, - onCollapsedChange = { notInterestedCollapsed = it }, - ) { - libraryRows("not-interested", notInterested) - } - } - if (!searchActive && !endReached) { + if (canLoadMore) { item(key = "load-more") { LaunchedEffect(nextOffset, items.size) { if (!loadingPage) loadPage() @@ -650,11 +613,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 private fun LibrarySearchField( value: String, + focused: Boolean, onValueChange: (String) -> Unit, onClear: () -> Unit, + onClose: () -> Unit, + onFocusChanged: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val shape = RoundedCornerShape(16.dp) @@ -665,18 +677,28 @@ private fun LibrarySearchField( .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 12.dp) .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) { - onClear() - true - } else { - false + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when { + event.key == Key.Escape && value.isNotBlank() -> { + onClear() + true + } + event.key == Key.Escape && focused -> { + onClose() + true + } + event.key.isEnterKey() && focused && value.isBlank() -> { + onClose() + true + } + else -> false } }, verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Filled.Search, - contentDescription = "Search library", + contentDescription = strings.searchLibrary, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.outline, ) @@ -692,32 +714,43 @@ private fun LibrarySearchField( singleLine = true, textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { onFocusChanged(it.isFocused) }, ) if (value.isBlank()) { Text( - "Search library", + strings.searchLibrary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f), maxLines = 1, ) } } - if (value.isNotBlank()) { - IconButton(onClick = onClear, modifier = Modifier.size(30.dp)) { - Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(17.dp)) + if (value.isNotBlank() || focused) { + IconButton( + onClick = { + if (value.isBlank()) onClose() else onClear() + }, + modifier = Modifier.size(30.dp), + ) { + Icon( + Icons.Filled.Close, + contentDescription = if (value.isBlank()) strings.closeSearch else strings.clearSearch, + modifier = Modifier.size(17.dp), + ) } } } } @Composable -private fun EmptyLibraryPane(modifier: Modifier = Modifier) { +private fun EmptyLibraryPane(filter: LibraryFilter, modifier: Modifier = Modifier) { Box(modifier, contentAlignment = Alignment.Center) { Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors()) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("No books indexed", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Text("Scan a folder containing FB2 or FB2.ZIP files.", style = MaterialTheme.typography.bodyMedium) + Text(strings.noBooksIn(filter.label), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(strings.scanFolderOrChooseFilter, style = MaterialTheme.typography.bodyMedium) } } } @@ -726,60 +759,7 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) { @Composable private fun EmptySearchPane(modifier: Modifier = Modifier) { Box(modifier, contentAlignment = Alignment.Center) { - Text("No matches", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline) - } -} - -private fun LazyListScope.librarySection( - key: String, - title: String, - itemCount: Int, - displayCount: Int?, - collapsed: Boolean, - onCollapsedChange: (Boolean) -> Unit, - content: LazyListScope.() -> Unit, -) { - if (itemCount == 0) return - item(key = "section-$key") { - LibrarySectionHeader( - text = title, - count = displayCount, - collapsed = collapsed, - onToggle = { onCollapsedChange(!collapsed) }, - ) - } - if (!collapsed) { - content() - } -} - -@Composable -private fun LibrarySectionHeader( - text: String, - count: Int?, - collapsed: Boolean, - onToggle: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(top = 10.dp, bottom = 4.dp, start = 8.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - if (collapsed) Icons.AutoMirrored.Filled.KeyboardArrowRight else Icons.Filled.KeyboardArrowDown, - contentDescription = if (collapsed) "Expand $text" else "Collapse $text", - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(18.dp), - ) - Text( - if (count == null) text else "$text ($count)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - textAlign = TextAlign.Center, - ) + Text(strings.noMatches, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline) } } @@ -804,7 +784,7 @@ private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modi fontWeight = FontWeight.SemiBold, ) Text( - "Imported ${progress.importedFiles}, skipped ${progress.skippedFiles}, failed ${progress.failedFiles}", + strings.importedSkippedFailed(progress.importedFiles, progress.skippedFiles, progress.failedFiles), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, ) @@ -850,14 +830,14 @@ private fun LibraryRow( if (item.favorite) { Icon( Icons.Filled.Favorite, - contentDescription = "Favorite", + contentDescription = strings.favorite, tint = Color(0xFFD32F2F), modifier = Modifier.size(favoriteIconSize), ) } } Text( - item.authors.joinToString().ifBlank { "Unknown author" }, + item.authors.joinToString().ifBlank { strings.unknownAuthor }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 1, @@ -872,11 +852,11 @@ private fun LibraryRow( } Box { 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 }) { DropdownMenuItem( - text = { Text("Open") }, + text = { Text(strings.open) }, onClick = { menuOpen = false actions.onOpen() @@ -885,7 +865,7 @@ private fun LibraryRow( HorizontalDivider() if (item.readingStatus != BookReadingStatus.READ) { DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text(strings.markAsRead) }, onClick = { menuOpen = false actions.onMarkAsRead() @@ -894,7 +874,7 @@ private fun LibraryRow( } if (item.readingStatus == BookReadingStatus.READ) { DropdownMenuItem( - text = { Text("Mark as unread") }, + text = { Text(strings.markAsUnread) }, onClick = { menuOpen = false actions.onMarkAsUnread() @@ -903,7 +883,7 @@ private fun LibraryRow( } if (item.readingStatus != BookReadingStatus.TO_READ) { DropdownMenuItem( - text = { Text("Mark to read") }, + text = { Text(strings.markToRead) }, onClick = { menuOpen = false actions.onMarkToRead() @@ -912,7 +892,7 @@ private fun LibraryRow( } if (item.readingStatus != BookReadingStatus.NEW) { 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 = { menuOpen = false actions.onRemoveMarks() @@ -920,14 +900,14 @@ private fun LibraryRow( ) } DropdownMenuItem( - text = { Text(if (item.favorite) "Remove favorite" else "Add favorite") }, + text = { Text(if (item.favorite) strings.removeFavorite else strings.addFavorite) }, onClick = { menuOpen = false actions.onFavoriteChange(!item.favorite) }, ) DropdownMenuItem( - text = { Text("Not interested") }, + text = { Text(strings.notInterested) }, onClick = { menuOpen = false actions.onNotInterested() @@ -935,7 +915,7 @@ private fun LibraryRow( ) HorizontalDivider() DropdownMenuItem( - text = { Text("Delete") }, + text = { Text(strings.delete) }, onClick = { menuOpen = false actions.onDelete() @@ -958,6 +938,67 @@ private data class LibraryItemActions( val onDelete: () -> Unit, ) +private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) { + ReadingNow, + RecentlyAdded(usesPagedLibrary = false), + MyLibrary, + ToRead, + Favorites, + Read, + NotInterested, + ; + + fun apply( + sourceItems: List, + recentlyAddedItems: List, + searchActive: Boolean, + ): List { + 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, + recentlyAddedItems: List, +): LibraryFilter = + when { + libraryItems.any { it.readingStatus == BookReadingStatus.READING } -> LibraryFilter.ReadingNow + recentlyAddedItems.any { it.readingStatus == BookReadingStatus.NEW } -> LibraryFilter.RecentlyAdded + else -> LibraryFilter.MyLibrary + } + @Composable private fun LibraryCover( item: LibraryItem, @@ -998,25 +1039,16 @@ private fun LibraryCover( private fun LibraryItem.libraryMetadataLine(): String = listOfNotNull( - "Favorite".takeIf { favorite }, - readingStatus.displayLabel, + strings.favorite.takeIf { favorite }, + strings.readingStatus(readingStatus), lastReadAt?.formatLastRead(), date?.yearOrRaw(), language?.uppercase(), format?.uppercase(), sizeBytes?.formatBytes(), - ).joinToString(" | ").ifBlank { "No metadata" } + ).joinToString(" | ").ifBlank { strings.noMetadata } -private val BookReadingStatus.displayLabel: String - get() = when (this) { - BookReadingStatus.NEW -> "New" - BookReadingStatus.TO_READ -> "To read" - BookReadingStatus.READING -> "Reading" - BookReadingStatus.READ -> "Read" - BookReadingStatus.NOT_INTERESTED -> "Not interested" - } - -private fun Long.formatLastRead(): String = "Last read ${formatLibraryLastReadTime(this)}" +private fun Long.formatLastRead(): String = "${strings.lastReadPrefix} ${formatLibraryLastReadTime(this)}" private fun String.yearOrRaw(): String = Regex("""\d{4}""").find(this)?.value ?: this @@ -1031,14 +1063,16 @@ private fun Long.formatBytes(): String = private fun List.replaceLibraryItem(item: LibraryItem): List = map { current -> if (current.fileId == item.fileId) item else current } +private fun Key.isEnterKey(): Boolean = this == Key.Enter || this == Key.NumPadEnter + private fun LibraryScanProgress.toCatalogScanMessage(): String { - val total = totalFiles ?: return "Scanned $scannedFiles books" + val total = totalFiles ?: return strings.scannedBooks(scannedFiles) val percent = if (total <= 0) { 100 } else { ((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 diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt new file mode 100644 index 0000000..c5037a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -0,0 +1,403 @@ +package net.sergeych.toread + +import net.sergeych.toread.storage.BookReadingStatus +import kotlin.math.roundToInt + +internal enum class AppLanguage { + English, + Russian, +} + +internal val appLanguage: AppLanguage + get() = if (platformLocaleTags().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 + +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 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 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 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 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 + } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index bad8a44..b4e7dd8 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -315,7 +315,7 @@ private fun DetailsPane( } item { Text( - "Sections", + strings.sections, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 6.dp, start = 2.dp), @@ -356,7 +356,7 @@ internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) { Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text( - book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" }, + book.authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor }, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary, ) @@ -374,9 +374,9 @@ internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit internal fun MetadataCard(book: Fb2Book) { Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - DetailLine("Genres", book.genres.joinToString().ifBlank { "Not specified" }) - DetailLine("Translator", book.translators.joinToString { it.displayName }.ifBlank { "Not specified" }) - DetailLine("Source", book.sourceLanguage?.uppercase() ?: "Not specified") + DetailLine(strings.genres, book.genres.joinToString().ifBlank { strings.notSpecified }) + DetailLine(strings.translator, book.translators.joinToString { it.displayName }.ifBlank { strings.notSpecified }) + DetailLine(strings.source, book.sourceLanguage?.uppercase() ?: strings.notSpecified) if (book.annotation.isNullOrBlank().not()) { Text(book.annotation.orEmpty(), style = MaterialTheme.typography.bodyMedium, lineHeight = 20.sp) } @@ -395,9 +395,9 @@ internal fun MetadataCard(book: Fb2Book) { internal fun StatsCard(stats: BookStats) { Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) { - StatBlock("Words", stats.words.formatCompact()) - StatBlock("Sections", stats.sections.toString()) - StatBlock("Images", stats.images.toString()) + StatBlock(strings.words, stats.words.formatCompact()) + StatBlock(strings.sections, stats.sections.toString()) + StatBlock(strings.images, stats.images.toString()) } } } @@ -586,7 +586,7 @@ private fun BookImage( contentScale = contentScale, ) } 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.flattenSections(depth: Int = 0): List = flatMapIndexed { index, section -> - val fallback = "Section ${index + 1}" + val fallback = strings.sectionFallback(index) listOf(ChapterEntry(section.title?.ifBlank { null } ?: fallback, section, depth)) + section.sections.flattenSections(depth + 1) } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index f2be42b..ce15706 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -108,7 +108,7 @@ internal fun BookView( if (status == BookReadingStatus.NEW) markedRead = false showMessage(successMessage) } else { - showMessage("Could not update book.") + showMessage(strings.couldNotUpdateBook) } } } @@ -186,16 +186,16 @@ internal fun BookView( } }, onMarkAsRead = { - setReadingStatus(BookReadingStatus.READ, "Marked as read.") + setReadingStatus(BookReadingStatus.READ, strings.markedAsRead()) }, onMarkToRead = { - setReadingStatus(BookReadingStatus.TO_READ, "Marked to read.") + setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead()) }, onNotInterested = { - setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.") + setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested()) }, onClearMarks = { - setReadingStatus(BookReadingStatus.NEW, "Cleared marks.") + setReadingStatus(BookReadingStatus.NEW, strings.removedMarks()) }, readingStatus = libraryItem?.readingStatus, favorite = libraryItem?.favorite == true, @@ -203,9 +203,9 @@ internal fun BookView( scope.launch { if (markLibraryFavorite(fileId, favorite)) { libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite) - showMessage(if (favorite) "Added to favorites." else "Removed from favorites.") + showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites()) } else { - showMessage("Could not update book.") + showMessage(strings.couldNotUpdateBook) } } }, @@ -213,14 +213,14 @@ internal fun BookView( onShare = { scope.launch { val shared = shareLibraryBookFile(fileId) - showMessage(if (shared) "Share opened." else "Could not share book.") + showMessage(if (shared) strings.shareOpened() else strings.couldNotShareBook()) } }, showViewFileAction = showViewFileAction, onViewFile = { scope.launch { 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, @@ -337,7 +337,7 @@ private fun CompactReaderTopBar( verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary) } Text( title, @@ -346,20 +346,20 @@ private fun CompactReaderTopBar( modifier = Modifier.weight(1f), ) IconButton(onClick = onThemeToggle) { - Icon(Icons.Filled.Palette, contentDescription = "Theme") + Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme) } if (showReadAloudAction) { IconButton(onClick = onReadAloud) { - Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud") + Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = strings.readAloud) } } Box { 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 }) { DropdownMenuItem( - text = { Text("Info...") }, + text = { Text(strings.info) }, onClick = { menuOpen = false onBookInfo() @@ -367,7 +367,7 @@ private fun CompactReaderTopBar( ) HorizontalDivider() DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text(strings.markAsRead) }, onClick = { menuOpen = false onMarkAsRead() @@ -375,7 +375,7 @@ private fun CompactReaderTopBar( ) if (readingStatus == BookReadingStatus.TO_READ) { DropdownMenuItem( - text = { Text("Remove to read") }, + text = { Text(strings.removeToRead) }, onClick = { menuOpen = false onClearMarks() @@ -383,7 +383,7 @@ private fun CompactReaderTopBar( ) } else { DropdownMenuItem( - text = { Text("Mark to read") }, + text = { Text(strings.markToRead) }, onClick = { menuOpen = false onMarkToRead() @@ -391,21 +391,21 @@ private fun CompactReaderTopBar( ) } DropdownMenuItem( - text = { Text("Not interested") }, + text = { Text(strings.notInterested) }, onClick = { menuOpen = false onNotInterested() }, ) DropdownMenuItem( - text = { Text("Clear marks") }, + text = { Text(strings.clearMarks) }, onClick = { menuOpen = false onClearMarks() }, ) DropdownMenuItem( - text = { Text(if (favorite) "Remove favorite" else "Add favorite") }, + text = { Text(if (favorite) strings.removeFavorite else strings.addFavorite) }, onClick = { menuOpen = false onFavoriteChange(!favorite) @@ -416,7 +416,7 @@ private fun CompactReaderTopBar( } if (showShareAction) { DropdownMenuItem( - text = { Text("Share") }, + text = { Text(strings.share) }, onClick = { menuOpen = false onShare() @@ -425,7 +425,7 @@ private fun CompactReaderTopBar( } if (showViewFileAction) { DropdownMenuItem( - text = { Text("View file") }, + text = { Text(strings.viewFile) }, onClick = { menuOpen = false onViewFile() @@ -434,7 +434,7 @@ private fun CompactReaderTopBar( } HorizontalDivider() DropdownMenuItem( - text = { Text("Delete") }, + text = { Text(strings.delete) }, onClick = { menuOpen = false onDelete() @@ -466,19 +466,19 @@ private fun ReadAloudPanel( verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack) { - Icon(Icons.Filled.Replay, contentDescription = "Previous sentence") + Icon(Icons.Filled.Replay, contentDescription = strings.previousSentence) } IconButton(onClick = onPlayStop) { Icon( 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) { - Icon(Icons.Filled.FastForward, contentDescription = "Next sentence") + Icon(Icons.Filled.FastForward, contentDescription = strings.nextSentence) } 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 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 } ?.let { "${it.label} (${it.localeTag})" } - ?: "Auto Russian" + ?: strings.autoRussian AlertDialog( onDismissRequest = onDismiss, - title = { Text("Read aloud") }, + title = { Text(strings.readAloud) }, text = { Column { - Text("TTS engine", style = MaterialTheme.typography.labelMedium) + Text(strings.ttsEngine, style = MaterialTheme.typography.labelMedium) Box { TextButton(onClick = { engineMenuOpen = true }) { Text(selectedEngineLabel) } DropdownMenu(expanded = engineMenuOpen, onDismissRequest = { engineMenuOpen = false }) { DropdownMenuItem( - text = { Text("System default") }, + text = { Text(strings.systemDefault) }, onClick = { engineMenuOpen = false onEngineSelected(null) @@ -528,14 +528,14 @@ private fun ReadAloudSettingsDialog( } } - Text("Offline voice", style = MaterialTheme.typography.labelMedium) + Text(strings.offlineVoice, style = MaterialTheme.typography.labelMedium) Box { TextButton(onClick = { voiceMenuOpen = true }, enabled = !state.loading && state.voices.isNotEmpty()) { Text(selectedVoiceLabel) } DropdownMenu(expanded = voiceMenuOpen, onDismissRequest = { voiceMenuOpen = false }) { DropdownMenuItem( - text = { Text("Auto Russian") }, + text = { Text(strings.autoRussian) }, onClick = { voiceMenuOpen = false onVoiceSelected(null) @@ -556,7 +556,7 @@ private fun ReadAloudSettingsDialog( if (state.loading) { Row(verticalAlignment = Alignment.CenterVertically) { 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) } @@ -564,7 +564,7 @@ private fun ReadAloudSettingsDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text("Done") + Text(strings.done) } }, ) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt index 43459c0..744889d 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt @@ -55,10 +55,10 @@ internal fun ScanScreen( Scaffold( topBar = { CenterAlignedTopAppBar( - title = { Text("Scan") }, + title = { Text(strings.scan) }, navigationIcon = { 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(), @@ -83,21 +83,21 @@ internal fun ScanScreen( OutlinedTextField( value = scanPath, onValueChange = { scanPath = it }, - label = { Text("Root folder") }, + label = { Text(strings.rootFolder) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { Button( onClick = { - message = "Scanning..." + message = strings.scanning onStartScan(scanPath) }, enabled = !busy && scanPath.isNotBlank(), ) { Icon(Icons.Filled.Scanner, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Scan") + Text(strings.scan) } FilledTonalButton( onClick = { @@ -109,7 +109,7 @@ internal fun ScanScreen( ) { Icon(Icons.Filled.FolderOpen, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Choose") + Text(strings.choose) } if (busy) { CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp) @@ -126,7 +126,7 @@ internal fun ScanScreen( Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) } libraryLogPath()?.let { - Text("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 = - buildString { - append("Checking") - currentFile?.takeIf(String::isNotBlank)?.let { append(" $it") } - append(". Found $scannedFiles supported files") - append(", imported $importedFiles") - append(", skipped $skippedFiles") - append(", failed $failedFiles") - append(".") - } + strings.checkingFile(currentFile, scannedFiles, importedFiles, skippedFiles, failedFiles) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt index 050b05f..a540d99 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt @@ -42,10 +42,10 @@ internal fun ErrorScreen(message: String, onBack: () -> Unit) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Card(shape = RoundedCornerShape(8.dp), colors = quietCardColors()) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("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) 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() internal fun Double?.progressLabel(): String = - this?.let { "Progress ${(it * 100).roundToInt()}%" } ?: "Progress not recorded" + strings.progressLabel(this) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt index 3c10ffd..ba6bbd2 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt @@ -9,13 +9,6 @@ internal fun ThemeMode.next(): ThemeMode = ThemeMode.DARK -> ThemeMode.SYSTEM } -internal val ThemeMode.displayName: String - get() = when (this) { - ThemeMode.LIGHT -> "Light" - ThemeMode.DARK -> "Dark" - ThemeMode.SYSTEM -> "System" - } - internal fun lightReaderColorScheme() = androidx.compose.material3.lightColorScheme( primary = Color(0xFF425D56), onPrimary = Color.White, diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index e7a54a2..160325d 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -75,7 +75,7 @@ actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = nul actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) { val chooser = JFileChooser(defaultLibraryScanPath()) chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - chooser.dialogTitle = "Choose library folder" + chooser.dialogTitle = strings.chooseLibraryFolder if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { chooser.selectedFile.absolutePath } else { diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/Localization.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/Localization.jvm.kt new file mode 100644 index 0000000..154149a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/Localization.jvm.kt @@ -0,0 +1,6 @@ +package net.sergeych.toread + +import java.util.Locale + +internal actual fun platformLocaleTags(): List = + listOf(Locale.getDefault().toLanguageTag()) diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt index 8f9580f..992fb42 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt @@ -6,8 +6,8 @@ import androidx.compose.ui.window.application fun main() = application { Window( onCloseRequest = ::exitApplication, - title = "toread", + title = strings.appName, ) { App() } -} \ No newline at end of file +} diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/Localization.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/Localization.web.kt new file mode 100644 index 0000000..366de71 --- /dev/null +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/Localization.web.kt @@ -0,0 +1,6 @@ +package net.sergeych.toread + +import kotlinx.browser.window + +internal actual fun platformLocaleTags(): List = + listOf(window.navigator.language)