preparing the publication

This commit is contained in:
Sergey Chernov 2026-05-23 17:30:14 +03:00
parent bad1e89c26
commit 128275402e
18 changed files with 242 additions and 22 deletions

View File

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

Binary file not shown.

View File

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

View File

@ -438,6 +438,18 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
} }
} }
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
}
}
actual suspend fun saveAppLocaleTag(localeTag: String?) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(AppLocaleTagFlag, localeTag?.takeIf { it.isNotBlank() })
}
}
actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) { actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull() db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
@ -781,4 +793,5 @@ private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""")
private const val ActiveReadingFileIdFlag = "active_reading_file_id" private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned" private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -51,6 +51,7 @@ private data class PendingLibraryDelete(
fun App() { fun App() {
var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) } var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) }
var systemDark by remember { mutableStateOf(isPlatformDarkTheme()) } var systemDark by remember { mutableStateOf(isPlatformDarkTheme()) }
var localePreferenceLoaded by remember { mutableStateOf(false) }
var toast by remember { mutableStateOf<AppToastData?>(null) } var toast by remember { mutableStateOf<AppToastData?>(null) }
var nextToastId by remember { mutableStateOf(0L) } var nextToastId by remember { mutableStateOf(0L) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -78,6 +79,13 @@ fun App() {
themeMode = loadThemeMode() themeMode = loadThemeMode()
} }
LaunchedEffect(Unit) {
selectedAppLocaleTag = loadAppLocaleTag()?.takeIf { saved ->
availableAppLanguages.any { it.localeTag == saved }
}
localePreferenceLoaded = true
}
LaunchedEffect(toast?.id) { LaunchedEffect(toast?.id) {
val current = toast ?: return@LaunchedEffect val current = toast ?: return@LaunchedEffect
delay(current.durationMillis) delay(current.durationMillis)
@ -89,17 +97,21 @@ fun App() {
MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) { MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
BookReaderApp( if (localePreferenceLoaded) {
onThemeToggle = { BookReaderApp(
val next = themeMode.next() onThemeToggle = {
themeMode = next val next = themeMode.next()
showToast(strings.themeChanged(next)) themeMode = next
scope.launch { showToast(strings.themeChanged(next))
saveThemeMode(next) scope.launch {
} saveThemeMode(next)
}, }
onShowToast = ::showToast, },
) onShowToast = ::showToast,
)
} else {
LoadingScreen(strings.loadingOpeningBook)
}
} }
AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter)) AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter))
} }

View File

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

View File

@ -24,7 +24,9 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
@ -101,6 +103,7 @@ internal fun LibraryScreen(
var recentlyAddedItems by remember(state.items) { mutableStateOf<List<LibraryItem>>(emptyList()) } var recentlyAddedItems by remember(state.items) { mutableStateOf<List<LibraryItem>>(emptyList()) }
var wasScanning by remember { mutableStateOf(false) } var wasScanning by remember { mutableStateOf(false) }
var settingsMenuOpen by remember { mutableStateOf(false) } var settingsMenuOpen by remember { mutableStateOf(false) }
var localeMenuOpen by remember { mutableStateOf(false) }
var autoScanDownloads by remember { mutableStateOf(true) } var autoScanDownloads by remember { mutableStateOf(true) }
var autoScanSettingLoaded by remember { mutableStateOf(false) } var autoScanSettingLoaded by remember { mutableStateOf(false) }
var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) } var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) }
@ -221,6 +224,7 @@ internal fun LibraryScreen(
fun rescanAllLibrary() { fun rescanAllLibrary() {
settingsMenuOpen = false settingsMenuOpen = false
localeMenuOpen = false
scope.launch { scope.launch {
busy = true busy = true
message = strings.rescanningLibrary message = strings.rescanningLibrary
@ -282,7 +286,10 @@ internal fun LibraryScreen(
} }
LaunchedEffect(searchFocused) { LaunchedEffect(searchFocused) {
if (searchFocused) settingsMenuOpen = false if (searchFocused) {
settingsMenuOpen = false
localeMenuOpen = false
}
} }
LaunchedEffect(searchActive, loadingPage, endReached, libraryItems, recentlyAdded) { LaunchedEffect(searchActive, loadingPage, endReached, libraryItems, recentlyAdded) {
@ -366,7 +373,13 @@ internal fun LibraryScreen(
IconButton(onClick = { settingsMenuOpen = true }) { IconButton(onClick = { settingsMenuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = strings.libraryOptions) Icon(Icons.Filled.MoreVert, contentDescription = strings.libraryOptions)
} }
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) { DropdownMenu(
expanded = settingsMenuOpen,
onDismissRequest = {
settingsMenuOpen = false
localeMenuOpen = false
},
) {
// DropdownMenuItem( // DropdownMenuItem(
// text = { Text("Rescan all library") }, // text = { Text("Rescan all library") },
// enabled = !busy && activeScan == null, // enabled = !busy && activeScan == null,
@ -385,6 +398,56 @@ internal fun LibraryScreen(
scope.launch { saveScanDownloadsAutomatically(next) } scope.launch { saveScanDownloadsAutomatically(next) }
}, },
) )
HorizontalDivider()
Box {
DropdownMenuItem(
text = { Text(strings.locale) },
trailingIcon = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
},
onClick = { localeMenuOpen = true },
)
DropdownMenu(expanded = localeMenuOpen, onDismissRequest = { localeMenuOpen = false }) {
availableAppLanguages.forEach { language ->
val checked = selectedAppLocaleTag == language.localeTag
DropdownMenuItem(
leadingIcon = {
if (checked) {
Icon(Icons.Filled.Check, contentDescription = null)
}
},
text = { Text(strings.appLanguageName(language)) },
onClick = {
selectedAppLocaleTag = language.localeTag
localeMenuOpen = false
settingsMenuOpen = false
scope.launch { saveAppLocaleTag(language.localeTag) }
},
)
}
HorizontalDivider()
DropdownMenuItem(
leadingIcon = {
if (selectedAppLocaleTag == null) {
Icon(Icons.Filled.Check, contentDescription = null)
}
},
text = { Text(strings.systemLocale) },
onClick = {
selectedAppLocaleTag = null
localeMenuOpen = false
settingsMenuOpen = false
scope.launch { saveAppLocaleTag(null) }
},
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = { Text(strings.appVersion(AppVersionDisplay)) },
enabled = false,
onClick = {},
)
} }
} }
} }

View File

@ -1,15 +1,22 @@
package net.sergeych.toread 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 net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt import kotlin.math.roundToInt
internal enum class AppLanguage { internal enum class AppLanguage(val localeTag: String) {
English, English("en"),
Russian, Russian("ru"),
} }
internal val availableAppLanguages: List<AppLanguage> = AppLanguage.entries
internal var selectedAppLocaleTag: String? by mutableStateOf(null)
internal val appLanguage: AppLanguage internal val appLanguage: AppLanguage
get() = if (platformLocaleTags().any { it.isRussianLanguageTag() }) { get() = if (effectiveAppLocaleTags().any { it.isRussianLanguageTag() }) {
AppLanguage.Russian AppLanguage.Russian
} else { } else {
AppLanguage.English AppLanguage.English
@ -23,6 +30,9 @@ internal val strings: AppStrings
internal expect fun platformLocaleTags(): List<String> internal expect fun platformLocaleTags(): List<String>
private fun effectiveAppLocaleTags(): List<String> =
selectedAppLocaleTag?.let(::listOf) ?: platformLocaleTags()
private fun String.isRussianLanguageTag(): Boolean = private fun String.isRussianLanguageTag(): Boolean =
substringBefore('-').substringBefore('_').equals("ru", ignoreCase = true) substringBefore('-').substringBefore('_').equals("ru", ignoreCase = true)
@ -53,6 +63,9 @@ internal open class AppStrings {
open val libraryOptions = "Library options" open val libraryOptions = "Library options"
open val scanDownloadsAutomatically = "Scan Downloads automatically" 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 scanFolder = "Scan folder"
open val chooseLibraryFilter = "Choose library filter" open val chooseLibraryFilter = "Choose library filter"
open val searchLibrary = "Search library" 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 couldNotRead(name: String): String = "Could not read $name"
open fun progressLabel(progress: Double?): String = open fun progressLabel(progress: Double?): String =
progress?.let { "Progress ${(it * 100).roundToInt()}%" } ?: "Progress not recorded" 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 sectionFallback(index: Int): String = "Section ${index + 1}"
open fun readingStatus(status: BookReadingStatus): String = open fun readingStatus(status: BookReadingStatus): String =
when (status) { when (status) {
@ -237,6 +255,9 @@ internal object RussianStrings : AppStrings() {
override val libraryOptions = "Параметры библиотеки" override val libraryOptions = "Параметры библиотеки"
override val scanDownloadsAutomatically = "Автоматически сканировать Загрузки" override val scanDownloadsAutomatically = "Автоматически сканировать Загрузки"
override val locale = "Локализация"
override val systemLocale = "Системная"
override fun appVersion(version: String): String = "Версия $version"
override val scanFolder = "Сканировать папку" override val scanFolder = "Сканировать папку"
override val chooseLibraryFilter = "Выбрать фильтр библиотеки" override val chooseLibraryFilter = "Выбрать фильтр библиотеки"
override val searchLibrary = "Поиск" override val searchLibrary = "Поиск"
@ -384,6 +405,11 @@ internal object RussianStrings : AppStrings() {
override fun couldNotRead(name: String): String = "Не удалось прочитать $name" override fun couldNotRead(name: String): String = "Не удалось прочитать $name"
override fun progressLabel(progress: Double?): String = override fun progressLabel(progress: Double?): String =
progress?.let { "Прогресс ${(it * 100).roundToInt()}%" } ?: "Прогресс не записан" 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 sectionFallback(index: Int): String = "Раздел ${index + 1}"
override fun readingStatus(status: BookReadingStatus): String = override fun readingStatus(status: BookReadingStatus): String =
when (status) { when (status) {

View File

@ -373,6 +373,18 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
} }
} }
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
}
}
actual suspend fun saveAppLocaleTag(localeTag: String?) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(AppLocaleTagFlag, localeTag?.takeIf { it.isNotBlank() })
}
}
actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) { actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull() db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
@ -589,4 +601,5 @@ private fun runCommand(vararg command: String): String? =
private const val ActiveReadingFileIdFlag = "active_reading_file_id" private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned" private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB