5 Commits
Author SHA1 Message Date
OneWay bc3fa85b9d Release 1.5.0
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
2026-08-18 00:16:27 +03:00
OneWay 457bbe43b6 Unescape any escaped character in article text
Apply the general DSL rule \X -> X instead of unescaping only brackets
and parentheses, so sequences like \! no longer show the backslash.
2026-08-18 00:15:53 +03:00
OneWay f751ac0d36 Harden DSL parsing against escapes and malformed tags
Fixed escape-aware headword parsing for \{, \}, \[ and trailing backslash.
Fixed O(n^2) tag scan and nested bracket handling in article rendering.
2026-08-16 11:13:46 +03:00
OneWay 910bc8313c Release 1.4.0
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
2026-08-06 14:26:03 +03:00
OneWay 9d18d0c927 Release 1.3.0
- Blocked swipe-to-delete on dictionary rows while a download, extract,
  import or indexing pass is running. Deleting mid-update removed the
  dictionary's source from preferences while the pipeline kept its own
  snapshot, so the post-update rescan re-created the dictionary with its
  update source permanently lost.
- Fixed the dictionary icon leaving the row while swiping to delete: the
  item now clips to its bounds and the delete button is a static overlay
  instead of a counter-translated child of the sliding row.
2026-08-02 11:09:47 +03:00
22 changed files with 403 additions and 194 deletions
+3 -3
View File
@@ -95,15 +95,15 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack
- Kotlin 2.4.10
- Jetpack Compose (BOM 2026.06.01)
- Jetpack Compose (BOM 2026.08.00)
- Material 3
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.0
- Paging 3.5.1
- OkHttp 5.4.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.3.0
- Android Gradle Plugin 9.3.1
## Build
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 5
versionName = "1.2.1"
versionCode = 8
versionName = "1.5.0"
vectorDrawables {
useSupportLibrary = true
@@ -0,0 +1,27 @@
package com.example.research.core.domain.usecase
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import java.io.File
object DictionarySourceFileMatcher {
fun matches(source: DictionarySource, fileName: String): Boolean =
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
fun matches(source: DictionarySource, dictionary: Dictionary): Boolean =
matches(source, File(dictionary.path).name)
fun installedSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>,
): List<DictionarySource> = sources.filter { source ->
dictionaries.any { dictionary -> matches(source, dictionary) }
}
fun installedSourcesForFileNames(
sources: List<DictionarySource>,
fileNames: Collection<String>,
): List<DictionarySource> = sources.filter { source ->
fileNames.any { fileName -> matches(source, fileName) }
}
}
@@ -43,12 +43,4 @@ class DictionarySourceValidator {
return ValidationResult.Valid
}
fun findMatchingSourceWithPrecomputedPrefixes(
dictionaryPrefix: String,
sourcesWithPrefixes: List<Pair<DictionarySource, String?>>
): DictionarySource? {
return sourcesWithPrefixes.find { (_, sourcePrefix) ->
sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true)
}?.first
}
}
@@ -30,18 +30,24 @@ class ManageDictionarySourcesUseCase(
preferencesManager.removeDictionarySource(sourceId)
}
suspend fun removeSourceForDictionary(dictionary: Dictionary) {
val dictionaryPrefix = dictionary.name
val sources = preferencesManager.dictionarySources.first()
val sourcesWithPrefixes = sources.map { source ->
source to DictionarySource.extractPrefix(source.urlTemplate)
}
suspend fun removeSourceForDictionary(
dictionary: Dictionary,
remainingDictionaryFileNames: Collection<String>?,
) {
if (remainingDictionaryFileNames == null) return
val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes)
if (matchingSource != null) {
preferencesManager.removeDictionarySource(matchingSource.id)
val sources = preferencesManager.dictionarySources.first()
val sourceIdsToRemove = sources
.filter { source ->
DictionarySourceFileMatcher.matches(source, dictionary) &&
remainingDictionaryFileNames.none { fileName ->
DictionarySourceFileMatcher.matches(source, fileName)
}
}
.map(DictionarySource::id)
preferencesManager.removeDictionarySources(sourceIdsToRemove)
}
sealed class AddSourceResult {
object Success : AddSourceResult()
@@ -24,10 +24,11 @@ object DslHeadwordParser {
}
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
val unescaped = unescape(trimmed)
return ParsedHeadword(
simplified = trimmed,
displayText = trimmed,
searchableText = trimmed.lowercase()
simplified = unescaped,
displayText = unescaped,
searchableText = unescaped.lowercase()
)
}
@@ -60,9 +61,9 @@ object DslHeadwordParser {
val searchableText = createSearchableText(trimmed)
return ParsedHeadword(
simplified = simplified,
displayText = displayText,
searchableText = searchableText
simplified = unescape(simplified),
displayText = unescape(displayText),
searchableText = unescape(searchableText)
)
}
@@ -129,8 +130,17 @@ object DslHeadwordParser {
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
return buildString(value.length) {
value.forEach { char ->
var index = 0
while (index < value.length) {
val char = value[index]
if (char == '\\' && index + 1 < value.length) {
append(char)
append(value[index + 1])
index += 2
} else {
if (char != '{' && char != '}') append(char)
index++
}
}
}
}
@@ -138,6 +148,10 @@ object DslHeadwordParser {
private fun firstFormattingTagIndex(value: String): Int {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index
}
@@ -149,8 +163,12 @@ object DslHeadwordParser {
private fun firstCurlyContent(value: String): String? {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1)
val end = matchingCurlyEnd(value, index + 1)
if (end > index + 1) {
return value.substring(index + 1, end)
}
@@ -161,7 +179,7 @@ object DslHeadwordParser {
}
private fun substringBeforeFirstBracket(value: String): String {
val bracketIndex = value.indexOf('[')
val bracketIndex = indexOfUnescaped(value, '[')
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
}
@@ -171,8 +189,12 @@ object DslHeadwordParser {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1)
val end = matchingCurlyEnd(value, index + 1)
if (end >= 0 && (removeEmpty || end > index + 1)) {
if (builder == null) {
builder = StringBuilder(value.length)
@@ -197,6 +219,10 @@ object DslHeadwordParser {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[') {
val tagEnd = formattingTagEnd(value, index)
if (tagEnd > index) {
@@ -217,6 +243,56 @@ object DslHeadwordParser {
}?.toString() ?: value
}
private fun matchingCurlyEnd(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '{' -> return -1
value[index] == '}' -> return index
else -> index++
}
}
return -1
}
private fun indexOfUnescaped(
value: String,
target: Char,
startIndex: Int = 0,
): Int {
var index = startIndex.coerceAtLeast(0)
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
} else if (value[index] == target) {
return index
} else {
index++
}
}
return -1
}
private fun unescape(value: String): String {
val firstEscape = value.indexOf('\\')
if (firstEscape < 0) return value
return buildString(value.length) {
append(value, 0, firstEscape)
var index = firstEscape
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
append(value[index + 1])
index += 2
} else {
append(value[index])
index++
}
}
}
}
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
val tokenStart = openBracketIndex + 1
if (tokenStart >= value.length) return -1
@@ -123,6 +123,24 @@ class LocalDictionaryRepository(
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
suspend fun listDictionaryPayloadFileNames(path: String): List<String>? =
withContext(ioDispatcher) {
try {
val directory = File(path)
when {
!directory.exists() -> {
if (directory.parentFile?.isDirectory == true) emptyList() else null
}
!directory.isDirectory -> null
else -> directory.listFiles { file -> isDictionaryPayloadFile(file) }
?.map(File::getName)
}
} catch (e: SecurityException) {
Log.w(TAG, "Failed to list dictionary files: ${e.message}")
null
}
}
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
if (isIndexingInProgress()) {
return@withContext OperationResult.Error(
@@ -371,9 +389,7 @@ class LocalDictionaryRepository(
val dir = File(path)
if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f ->
f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz"))
}?.map { file ->
return dir.listFiles { file -> isDictionaryPayloadFile(file) }?.map { file ->
DiscoveredFile(
name = file.name,
localPath = file.absolutePath,
@@ -382,6 +398,13 @@ class LocalDictionaryRepository(
} ?: emptyList()
}
private fun isDictionaryPayloadFile(file: File): Boolean =
file.isFile && (
file.name.endsWith(".dsl") ||
file.name.endsWith(".dsl.dz") ||
file.name.endsWith(".dsl.gz")
)
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
@@ -460,20 +483,17 @@ class LocalDictionaryRepository(
}
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
try {
dictionaryStateMutex.withLock { removeDictionaryFromState(dictionary.path) }
launch(ioDispatcher) {
try {
val deleteErrors = mutableListOf<String>()
return try {
val (failure, cleanupErrors) = withContext(ioDispatcher) {
val dictFile = File(dictionary.path)
if (dictFile.exists()) {
if (!dictFile.delete()) {
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}")
}
if (dictFile.exists() && !dictFile.delete()) {
return@withContext Pair(
"Failed to delete dictionary file: ${dictFile.absolutePath}",
emptyList<String>(),
)
}
val deleteErrors = mutableListOf<String>()
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
File(dictionary.indexPath)
} else {
@@ -481,12 +501,20 @@ class LocalDictionaryRepository(
}
deleteIndexFiles(indexFile, deleteErrors)
if (!dictFile.exists()) {
dictionaryStateMutex.withLock {
preferencesManager.removeDictionaryActiveState(dictionary.path)
removeDictionaryFromState(dictionary.path)
Pair<String?, List<String>>(null, deleteErrors)
}
if (failure != null) {
return OperationResult.Error(failure)
}
dictionaryStateMutex.withLock {
try {
preferencesManager.removeDictionaryActiveState(dictionary.path)
} catch (e: Exception) {
Log.w(TAG, "Failed to clear dictionary active state: ${e.message}")
}
removeDictionaryFromState(dictionary.path)
}
try {
@@ -495,18 +523,16 @@ class LocalDictionaryRepository(
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
}
if (deleteErrors.isNotEmpty()) {
val message = deleteErrors.joinToString("; ")
Log.w(TAG, "Dictionary deletion completed with errors: $message")
}
} catch (e: Exception) {
Log.e(TAG, "Background deletion failed: ${e.message}", e)
}
if (cleanupErrors.isNotEmpty()) {
Log.w(
TAG,
"Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}"
)
}
return OperationResult.Success(Unit)
OperationResult.Success(Unit)
} catch (e: Exception) {
return OperationResult.Error("Failed to delete dictionary", e)
OperationResult.Error("Failed to delete dictionary", e)
}
}
@@ -343,6 +343,8 @@ class DictionaryDownloader(
context.getString(R.string.download_connection_reset)
e is SecurityException ->
context.getString(R.string.download_no_write_permission)
e.isNetworkError() ->
context.getString(R.string.download_network_failed)
else ->
context.getString(R.string.download_error)
}
@@ -23,7 +23,6 @@ object DslAnnotatedParser {
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
)
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
data class ColorScheme(
val secondaryText: Color,
@@ -101,7 +100,6 @@ object DslAnnotatedParser {
'\\' -> {
if (i + 1 < length) {
val next = dsl[i + 1]
if (next in ESCAPED_CHARS) {
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
@@ -110,7 +108,6 @@ object DslAnnotatedParser {
i += 2
continue
}
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
@@ -119,7 +116,7 @@ object DslAnnotatedParser {
i++
}
'[' -> {
val end = dsl.indexOf(']', i + 1)
val end = tagCloseIndex(dsl, i + 1)
if (end != -1) {
val tagStart = i + 1
var tagEnd = end
@@ -228,6 +225,19 @@ object DslAnnotatedParser {
splitOversizedBlocks(result)
}
private fun tagCloseIndex(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '[' -> return -1
value[index] == ']' -> return index
else -> index++
}
}
return -1
}
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
return blocks.flatMap { block ->
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -135,7 +136,9 @@ private fun SearchField(
Icon(
painter = searchIcon,
contentDescription = null,
modifier = Modifier.size(24.dp)
modifier = Modifier
.offset(x = 4.dp)
.size(24.dp)
)
},
trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -146,6 +149,7 @@ private fun SearchField(
onClearQuery()
},
modifier = Modifier
.padding(end = 12.dp)
.testTag("clear_search")
.semantics { testTagsAsResourceId = true }
) {
@@ -11,6 +11,7 @@ import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult
@@ -30,7 +31,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ConcurrentHashMap
private data class DictionaryStateInputs(
val theme: AppTheme,
@@ -79,10 +80,12 @@ class SettingsViewModel(
private val language = MutableStateFlow("system")
private val hasCompletedStartupScan = MutableStateFlow(false)
private val pendingSourceUrls = mutableSetOf<String>()
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
private var isDownloadInProgress = false
private var cancelRefreshPending = false
private var errorStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init {
setupStateObservation()
@@ -146,31 +149,30 @@ class SettingsViewModel(
is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true
cancelRefreshPending = false
errorStateHandled = false
}
is DownloadState.Success -> {
pendingSourceUrls.clear()
}
is DownloadState.Error -> {
isDownloadInProgress = false
if (!errorStateHandled) {
errorStateHandled = true
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
} else when (statusBeforeDownload) {
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
else -> statusBeforeDownload
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate
}
if (source != null) {
val hasDictionary = dictionaryState.dictionaries.any { dict ->
DictionarySource.matchesDictionaryFile(
urlTemplate,
File(dict.path).name
)
val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
DictionarySourceFileMatcher.matches(source, dictionary)
}
if (!hasDictionary) {
manageDictionarySourcesUseCase.removeSource(source.id)
}
@@ -179,6 +181,7 @@ class SettingsViewModel(
pendingSourceUrls.clear()
}
}
}
is DownloadState.Cancelled -> {
isDownloadInProgress = false
cancelRefreshPending = true
@@ -264,18 +267,21 @@ class SettingsViewModel(
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
return try {
val actualDictionaries = localDictionaryRepository.dictionaries.first()
val hasDictionaries = actualDictionaries.isNotEmpty()
if (!hasDictionaries) {
return DictionaryStatus.Empty
}
var sources = preferencesManager.dictionarySources.first()
if (downloadManager.downloadState.value !is DownloadState.Loading &&
if (pendingSourceUrls.isEmpty() &&
downloadManager.downloadState.value !is DownloadState.Loading &&
downloadManager.downloadState.value !is DownloadState.Extracting
) {
val installedSources = installedSources(sources, actualDictionaries)
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
val dictionaryFileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
)
if (dictionaryFileNames != null) {
val installedSources = DictionarySourceFileMatcher.installedSourcesForFileNames(
sources,
dictionaryFileNames,
)
val installedSourceIds = installedSources
.mapTo(mutableSetOf(), DictionarySource::id)
val staleSourceIds = sources
.filterNot { it.id in installedSourceIds }
.map(DictionarySource::id)
@@ -284,6 +290,12 @@ class SettingsViewModel(
sources = installedSources
}
}
}
if (actualDictionaries.isEmpty()) {
return DictionaryStatus.Empty
}
val enabledSources = installedEnabledSources(sources, actualDictionaries)
if (enabledSources.isEmpty()) {
@@ -338,21 +350,31 @@ class SettingsViewModel(
private fun deleteDictionary(dictionary: Dictionary) {
viewModelScope.launch {
localDictionaryRepository.deleteDictionary(dictionary)
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary)
val remainingDictionaries = localDictionaryRepository.dictionaries.first()
if (remainingDictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
when (localDictionaryRepository.deleteDictionary(dictionary)) {
is OperationResult.Success -> {
val remainingDictionaryFileNames =
localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
)
manageDictionarySourcesUseCase.removeSourceForDictionary(
dictionary = dictionary,
remainingDictionaryFileNames = remainingDictionaryFileNames,
)
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
is OperationResult.Error -> {
effectChannel.send(
getApplication<Application>().getString(R.string.error_delete_dictionary)
)
}
}
}
}
private fun addDictionarySources(urlTemplates: List<String>) {
viewModelScope.launch {
try {
val validUrls = mutableListOf<String>()
urlTemplates.forEach { urlTemplate ->
@@ -361,23 +383,30 @@ class SettingsViewModel(
return@forEach
}
val normalizedUrl = DictionarySource.normalizeTemplate(trimmed)
if (!pendingSourceUrls.add(normalizedUrl)) {
return@forEach
}
try {
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
validUrls.add(trimmed)
}
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
pendingSourceUrls.remove(normalizedUrl)
// Validation failed, skip this source
}
}
} catch (e: Exception) {
pendingSourceUrls.remove(normalizedUrl)
android.util.Log.e("SettingsViewModel", "Error adding dictionary source", e)
}
}
if (validUrls.isNotEmpty()) {
startDownloadForSources(validUrls)
}
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
}
}
}
@@ -390,6 +419,7 @@ class SettingsViewModel(
return@launch
}
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking
isDownloadInProgress = true
@@ -413,6 +443,7 @@ class SettingsViewModel(
return@launch
}
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking
val installedSources = installedEnabledSources(
@@ -448,14 +479,8 @@ class SettingsViewModel(
private fun installedSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>
): List<DictionarySource> {
val installedFileNames = dictionaries.map { File(it.path).name }
return sources.filter { source ->
installedFileNames.any { fileName ->
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
}
}
}
): List<DictionarySource> =
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
private fun cancelDownload() {
// Cancel the ViewModel-side scan job so no further status recomputes
@@ -501,10 +526,11 @@ class SettingsViewModel(
is OperationResult.Success -> {
val dictionaries = localDictionaryRepository.dictionaries.first()
if (dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
isDownloadInProgress = false
} else {
if (isDownloadInProgress) {
} else if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
@@ -512,7 +538,6 @@ class SettingsViewModel(
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
}
if (result.data > 0) {
viewModelScope.launch(Dispatchers.Default) {
@@ -30,6 +30,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -81,7 +82,8 @@ fun DictionaryListItem(
dictionary: Dictionary,
onToggle: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
canDelete: Boolean = true
) {
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
@@ -93,7 +95,9 @@ fun DictionaryListItem(
val offsetAnim = remember { Animatable(0f) }
var rawOffset by remember { mutableFloatStateOf(0f) }
val isDeleteRevealed by remember { derivedStateOf { rawOffset <= -swipeThresholdPx } }
val isDeleteRevealed by remember(canDelete) {
derivedStateOf { canDelete && rawOffset <= -swipeThresholdPx }
}
LaunchedEffect(isDeleteRevealed) {
if (isDeleteRevealed) {
@@ -101,6 +105,13 @@ fun DictionaryListItem(
}
}
LaunchedEffect(canDelete) {
if (!canDelete) {
rawOffset = 0f
offsetAnim.snapTo(0f)
}
}
val currentOnDelete by rememberUpdatedState(onDelete)
val currentOnToggle by rememberUpdatedState(onToggle)
@@ -121,6 +132,8 @@ fun DictionaryListItem(
Box(
modifier = modifier
.fillMaxWidth()
.height(DictionaryItemHeight)
.clipToBounds()
.testTag("dictionary_item")
) {
Row(
@@ -129,7 +142,8 @@ fun DictionaryListItem(
.height(DictionaryItemHeight)
.background(MaterialTheme.colorScheme.surfaceVariant)
.graphicsLayer { translationX = offsetAnim.value }
.pointerInput(maxSwipePx, swipeThresholdPx) {
.pointerInput(canDelete, maxSwipePx, swipeThresholdPx) {
if (!canDelete) return@pointerInput
detectHorizontalDragGestures(
onDragStart = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -222,25 +236,11 @@ fun DictionaryListItem(
}
if (isDeleteRevealed) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnDelete()
scope.launch { offsetAnim.snapTo(0f) }
rawOffset = 0f
},
Box(
modifier = Modifier
.padding(end = ItemHorizontalPadding)
.graphicsLayer { translationX = -offsetAnim.value }
.size(IconButtonSize)
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.dictionary_delete),
tint = DeleteIconColor,
modifier = Modifier.size(IconSize)
)
}
} else {
Switch(
checked = dictionary.isActive,
@@ -257,5 +257,27 @@ fun DictionaryListItem(
)
}
}
if (isDeleteRevealed) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnDelete()
scope.launch { offsetAnim.snapTo(0f) }
rawOffset = 0f
},
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = ItemHorizontalPadding)
.size(IconButtonSize)
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.dictionary_delete),
tint = DeleteIconColor,
modifier = Modifier.size(IconSize)
)
}
}
}
}
@@ -166,7 +166,8 @@ fun DictionaryManagement(
onDeleteDictionary = onDeleteDictionary,
modifier = Modifier
.fillMaxWidth()
.then(backgroundModifier)
.then(backgroundModifier),
canDelete = !isInProgress
)
if (!isInProgress) {
@@ -246,7 +247,8 @@ private fun DictionaryListSection(
dictionaries: List<Dictionary>,
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
canDelete: Boolean = true
) {
Column(
modifier = modifier,
@@ -256,7 +258,8 @@ private fun DictionaryListSection(
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) }
onDelete = { onDeleteDictionary(dictionary) },
canDelete = canDelete
)
}
}
+2
View File
@@ -7,6 +7,7 @@
<string name="button_cancel_download">Отмена</string>
<string name="action_ok">ОК</string>
<string name="download_error">Ошибка загрузки словарей</string>
<string name="download_network_failed">Нет связи с сервером словарей. Проверьте сеть и попробуйте снова</string>
<string name="language">Язык</string>
<string name="language_english">Английский</string>
<string name="language_russian">Русский</string>
@@ -95,6 +96,7 @@
</plurals>
<string name="dictionary_not_indexed">• Не индексирован</string>
<string name="dictionary_delete">Удалить словарь</string>
<string name="error_delete_dictionary">Не удалось удалить словарь</string>
<string name="dictionaries_tap_to_update">Нажмите для обновления</string>
<string name="notification_import_title">Импорт словарей</string>
<string name="notification_import_success_title">Импорт завершён</string>
+2
View File
@@ -7,6 +7,7 @@
<string name="button_cancel_download">Cancel</string>
<string name="action_ok">OK</string>
<string name="download_error">Dictionary download failed</string>
<string name="download_network_failed">No connection to the dictionary server. Check your network and try again</string>
<string name="language">Language</string>
<string name="language_english">English</string>
<string name="language_russian">Russian</string>
@@ -91,6 +92,7 @@
</plurals>
<string name="dictionary_not_indexed">• Not indexed</string>
<string name="dictionary_delete">Delete dictionary</string>
<string name="error_delete_dictionary">Failed to delete dictionary</string>
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
<string name="notification_import_title">Importing dictionaries</string>
<string name="notification_import_success_title">Import completed</string>
@@ -0,0 +1,2 @@
- Dictionaries can no longer be deleted while an update is running, which used to lose their update source
- Fixed the dictionary icon sliding outside the row while swiping to delete
@@ -0,0 +1 @@
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
@@ -0,0 +1,3 @@
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
@@ -0,0 +1,2 @@
- Словари больше нельзя удалить во время обновления — раньше при этом терялся источник обновления
- Исправлен выход иконки словаря за границы строки при свайпе
@@ -0,0 +1 @@
- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL
@@ -0,0 +1,3 @@
- Карточка обновления словарей теперь появляется снова после неудачной загрузки
- Исправлено отображение заголовков и статей с экранированными символами
- Исправлено медленное отображение статей с большим количеством скобок
+2 -2
View File
@@ -2,14 +2,14 @@
aboutlibraries = "15.0.4"
activity_compose = "1.13.0"
agp = "9.3.1"
compose_bom = "2026.06.01"
compose_bom = "2026.08.00"
core = "1.19.0"
datastore_preferences = "1.2.1"
documentfile = "1.1.0"
kotlin = "2.4.10"
lifecycle_runtime_ktx = "2.11.0"
okhttp = "5.4.0"
paging = "3.5.0"
paging = "3.5.1"
profileinstaller = "1.4.1"
kotlinx_coroutines = "1.11.0"
kotlinx_serialization = "1.11.0"