diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b609148..8cd26cb 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -2,6 +2,10 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget +val appVersionName = "1.0" +val appVersionCode = 1 +val appVersionDisplay = "$appVersionName.$appVersionCode" + plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) @@ -10,6 +14,38 @@ plugins { alias(libs.plugins.composeHotReload) } +abstract class GenerateAppVersionConstantsTask : DefaultTask() { + @get:Input + abstract val versionName: Property + + @get:Input + abstract val versionCode: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val outputFile = outputDir.file("net/sergeych/toread/AppVersion.kt").get().asFile + outputFile.parentFile.mkdirs() + outputFile.writeText( + """ + package net.sergeych.toread + + internal const val AppVersionName = "${versionName.get()}" + internal const val AppVersionCode = ${versionCode.get()} + internal const val AppVersionDisplay = "${versionName.get()}.${versionCode.get()}" + """.trimIndent() + "\n", + ) + } +} + +val generateAppVersionConstants by tasks.registering(GenerateAppVersionConstantsTask::class) { + versionName.set(appVersionName) + versionCode.set(appVersionCode) + outputDir.set(layout.buildDirectory.dir("generated/appVersion/commonMain/kotlin")) +} + kotlin { jvmToolchain(17) @@ -33,6 +69,9 @@ kotlin { } sourceSets { + commonMain { + kotlin.srcDir(generateAppVersionConstants) + } androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) @@ -69,8 +108,8 @@ android { applicationId = "net.sergeych.toread" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" + versionCode = appVersionCode + versionName = appVersionName } packaging { resources { @@ -100,7 +139,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "To Read" - packageVersion = "1.0.0" + packageVersion = appVersionDisplay macOS { iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns")) diff --git a/composeApp/release/baselineProfiles/0/composeApp-release.dm b/composeApp/release/baselineProfiles/0/composeApp-release.dm new file mode 100644 index 0000000..ca52bba Binary files /dev/null and b/composeApp/release/baselineProfiles/0/composeApp-release.dm differ diff --git a/composeApp/release/baselineProfiles/1/composeApp-release.dm b/composeApp/release/baselineProfiles/1/composeApp-release.dm new file mode 100644 index 0000000..c996fdf Binary files /dev/null and b/composeApp/release/baselineProfiles/1/composeApp-release.dm differ diff --git a/composeApp/release/composeApp-release.apk b/composeApp/release/composeApp-release.apk new file mode 100644 index 0000000..008e5a0 Binary files /dev/null and b/composeApp/release/composeApp-release.apk differ diff --git a/composeApp/release/output-metadata.json b/composeApp/release/output-metadata.json new file mode 100644 index 0000000..164e284 --- /dev/null +++ b/composeApp/release/output-metadata.json @@ -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 +} \ No newline at end of file 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 f3a019e..433e407 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -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) { openLibraryDatabase().useLibrary { db -> db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull() @@ -781,4 +793,5 @@ private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""") private const val ActiveReadingFileIdFlag = "active_reading_file_id" private const val ThemeModeFlag = "theme_mode" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" +private const val AppLocaleTagFlag = "app_locale_tag" private const val DownloadsWasScannedFlag = "downloads_was_scanned" diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml index 6f2acb4..b3ee0da 100644 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f2acb4..b3ee0da 100644 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index c076bef..fc811ac 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -51,6 +51,7 @@ private data class PendingLibraryDelete( fun App() { var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) } var systemDark by remember { mutableStateOf(isPlatformDarkTheme()) } + var localePreferenceLoaded by remember { mutableStateOf(false) } var toast by remember { mutableStateOf(null) } var nextToastId by remember { mutableStateOf(0L) } val scope = rememberCoroutineScope() @@ -78,6 +79,13 @@ fun App() { themeMode = loadThemeMode() } + LaunchedEffect(Unit) { + selectedAppLocaleTag = loadAppLocaleTag()?.takeIf { saved -> + availableAppLanguages.any { it.localeTag == saved } + } + localePreferenceLoaded = true + } + LaunchedEffect(toast?.id) { val current = toast ?: return@LaunchedEffect delay(current.durationMillis) @@ -89,17 +97,21 @@ fun App() { MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) { Box(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - BookReaderApp( - onThemeToggle = { - val next = themeMode.next() - themeMode = next - showToast(strings.themeChanged(next)) - scope.launch { - saveThemeMode(next) - } - }, - onShowToast = ::showToast, - ) + if (localePreferenceLoaded) { + BookReaderApp( + onThemeToggle = { + val next = themeMode.next() + themeMode = next + showToast(strings.themeChanged(next)) + scope.launch { + saveThemeMode(next) + } + }, + onShowToast = ::showToast, + ) + } else { + LoadingScreen(strings.loadingOpeningBook) + } } AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter)) } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 5f3fb86..49ff594 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -145,6 +145,10 @@ expect suspend fun loadScanDownloadsAutomatically(): Boolean expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean) +expect suspend fun loadAppLocaleTag(): String? + +expect suspend fun saveAppLocaleTag(localeTag: String?) + expect suspend fun loadDownloadsWasScanned(): Boolean expect suspend fun saveDownloadsWasScanned(scanned: Boolean) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 5b1ebae..87ae5da 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -24,7 +24,9 @@ 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.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -101,6 +103,7 @@ internal fun LibraryScreen( var recentlyAddedItems by remember(state.items) { mutableStateOf>(emptyList()) } var wasScanning by remember { mutableStateOf(false) } var settingsMenuOpen by remember { mutableStateOf(false) } + var localeMenuOpen by remember { mutableStateOf(false) } var autoScanDownloads by remember { mutableStateOf(true) } var autoScanSettingLoaded by remember { mutableStateOf(false) } var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) } @@ -221,6 +224,7 @@ internal fun LibraryScreen( fun rescanAllLibrary() { settingsMenuOpen = false + localeMenuOpen = false scope.launch { busy = true message = strings.rescanningLibrary @@ -282,7 +286,10 @@ internal fun LibraryScreen( } LaunchedEffect(searchFocused) { - if (searchFocused) settingsMenuOpen = false + if (searchFocused) { + settingsMenuOpen = false + localeMenuOpen = false + } } LaunchedEffect(searchActive, loadingPage, endReached, libraryItems, recentlyAdded) { @@ -366,7 +373,13 @@ internal fun LibraryScreen( IconButton(onClick = { settingsMenuOpen = true }) { Icon(Icons.Filled.MoreVert, contentDescription = strings.libraryOptions) } - DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) { + DropdownMenu( + expanded = settingsMenuOpen, + onDismissRequest = { + settingsMenuOpen = false + localeMenuOpen = false + }, + ) { // DropdownMenuItem( // text = { Text("Rescan all library") }, // enabled = !busy && activeScan == null, @@ -385,6 +398,56 @@ internal fun LibraryScreen( scope.launch { saveScanDownloadsAutomatically(next) } }, ) + HorizontalDivider() + Box { + DropdownMenuItem( + text = { Text(strings.locale) }, + trailingIcon = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + onClick = { localeMenuOpen = true }, + ) + DropdownMenu(expanded = localeMenuOpen, onDismissRequest = { localeMenuOpen = false }) { + availableAppLanguages.forEach { language -> + val checked = selectedAppLocaleTag == language.localeTag + DropdownMenuItem( + leadingIcon = { + if (checked) { + Icon(Icons.Filled.Check, contentDescription = null) + } + }, + text = { Text(strings.appLanguageName(language)) }, + onClick = { + selectedAppLocaleTag = language.localeTag + localeMenuOpen = false + settingsMenuOpen = false + scope.launch { saveAppLocaleTag(language.localeTag) } + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + leadingIcon = { + if (selectedAppLocaleTag == null) { + Icon(Icons.Filled.Check, contentDescription = null) + } + }, + text = { Text(strings.systemLocale) }, + onClick = { + selectedAppLocaleTag = null + localeMenuOpen = false + settingsMenuOpen = false + scope.launch { saveAppLocaleTag(null) } + }, + ) + } + } + HorizontalDivider() + DropdownMenuItem( + text = { Text(strings.appVersion(AppVersionDisplay)) }, + enabled = false, + onClick = {}, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt index c5037a2..1a0a3d9 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -1,15 +1,22 @@ 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 { - English, - Russian, +internal enum class AppLanguage(val localeTag: String) { + English("en"), + Russian("ru"), } +internal val availableAppLanguages: List = AppLanguage.entries + +internal var selectedAppLocaleTag: String? by mutableStateOf(null) + internal val appLanguage: AppLanguage - get() = if (platformLocaleTags().any { it.isRussianLanguageTag() }) { + get() = if (effectiveAppLocaleTags().any { it.isRussianLanguageTag() }) { AppLanguage.Russian } else { AppLanguage.English @@ -23,6 +30,9 @@ internal val strings: AppStrings internal expect fun platformLocaleTags(): List +private fun effectiveAppLocaleTags(): List = + selectedAppLocaleTag?.let(::listOf) ?: platformLocaleTags() + private fun String.isRussianLanguageTag(): Boolean = substringBefore('-').substringBefore('_').equals("ru", ignoreCase = true) @@ -53,6 +63,9 @@ internal open class AppStrings { 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" @@ -197,6 +210,11 @@ internal open class AppStrings { 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) { @@ -237,6 +255,9 @@ internal object RussianStrings : AppStrings() { 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 = "Поиск" @@ -384,6 +405,11 @@ internal object RussianStrings : AppStrings() { 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) { 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 160325d..68fa670 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -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) { openLibraryDatabase().useLibrary { db -> 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 ThemeModeFlag = "theme_mode" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" +private const val AppLocaleTagFlag = "app_locale_tag" private const val DownloadsWasScannedFlag = "downloads_was_scanned" diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index 2a63d5d..e099e5d 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -77,6 +77,17 @@ actual suspend fun loadScanDownloadsAutomatically(): Boolean = true actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit +actual suspend fun loadAppLocaleTag(): String? = + window.localStorage.getItem(AppLocaleStorageKey)?.takeIf { it.isNotBlank() } + +actual suspend fun saveAppLocaleTag(localeTag: String?) { + if (localeTag.isNullOrBlank()) { + window.localStorage.removeItem(AppLocaleStorageKey) + } else { + window.localStorage.setItem(AppLocaleStorageKey, localeTag) + } +} + actual suspend fun loadDownloadsWasScanned(): Boolean = false actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = Unit @@ -99,6 +110,8 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit { actual fun libraryLogPath(): String? = null +private const val AppLocaleStorageKey = "toread.appLocaleTag" + actual fun formatLibraryLastReadTime(millis: Long): String { val totalMinutes = millis / 60_000L val minute = (totalMinutes % 60).toString().padStart(2, '0') diff --git a/screenshots/Screenshot_20260523_170411.png b/screenshots/Screenshot_20260523_170411.png new file mode 100644 index 0000000..40eaf27 Binary files /dev/null and b/screenshots/Screenshot_20260523_170411.png differ diff --git a/screenshots/Screenshot_20260523_170540.png b/screenshots/Screenshot_20260523_170540.png new file mode 100644 index 0000000..1ac38cf Binary files /dev/null and b/screenshots/Screenshot_20260523_170540.png differ diff --git a/screenshots/Screenshot_20260523_170604 (101). b/screenshots/Screenshot_20260523_170604 (101). new file mode 100644 index 0000000..e94e184 Binary files /dev/null and b/screenshots/Screenshot_20260523_170604 (101). differ diff --git a/screenshots/Screenshot_20260523_170621.png b/screenshots/Screenshot_20260523_170621.png new file mode 100644 index 0000000..7f73934 Binary files /dev/null and b/screenshots/Screenshot_20260523_170621.png differ