From 911e8b61f6b91de7a92506c9d8e0fcc1b633cee1 Mon Sep 17 00:00:00 2001 From: OneWay Date: Mon, 12 Oct 2026 15:38:55 +0800 Subject: [PATCH] feat(settings): badge dictionaries that have updates Report which sources have a newer file instead of a single flag, and show a Material badge at the top-end corner of each such dictionary's icon. The section icon carries the badge while the section is collapsed. --- .../com/example/research/DictionaryStatus.kt | 2 +- .../common/ui/components/BadgedIcon.kt | 46 ++++++++++++++ .../common/ui/components/CollapsibleHeader.kt | 10 +-- .../download/repository/DictionaryChecker.kt | 63 ++++++------------- .../repository/DictionaryRepository.kt | 13 ++-- .../research/ui/settings/SettingsViewModel.kt | 12 ++-- .../settings/components/DictionaryListItem.kt | 14 +++-- .../components/DictionaryManagement.kt | 34 ++++++++-- app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 10 files changed, 120 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/com/example/research/common/ui/components/BadgedIcon.kt diff --git a/app/src/main/java/com/example/research/DictionaryStatus.kt b/app/src/main/java/com/example/research/DictionaryStatus.kt index 68071c7..3d57862 100644 --- a/app/src/main/java/com/example/research/DictionaryStatus.kt +++ b/app/src/main/java/com/example/research/DictionaryStatus.kt @@ -7,7 +7,7 @@ sealed interface DictionaryStatus { data object Unknown : DictionaryStatus data object Checking : DictionaryStatus data object UpToDate : DictionaryStatus - data object NeedsUpdate : DictionaryStatus + data class NeedsUpdate(val sourceIds: Set) : DictionaryStatus data object Empty : DictionaryStatus data class Error(val message: String) : DictionaryStatus } diff --git a/app/src/main/java/com/example/research/common/ui/components/BadgedIcon.kt b/app/src/main/java/com/example/research/common/ui/components/BadgedIcon.kt new file mode 100644 index 0000000..cd9052b --- /dev/null +++ b/app/src/main/java/com/example/research/common/ui/components/BadgedIcon.kt @@ -0,0 +1,46 @@ +package com.example.research.common.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Badge +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val BadgeOutset = 4.dp + +@Composable +fun BadgedIcon( + painter: Painter, + tint: Color, + size: Dp, + badgeDescription: String?, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.size(size)) { + Icon( + painter = painter, + contentDescription = null, + tint = tint, + modifier = Modifier.size(size) + ) + if (badgeDescription != null) { + Badge( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = BadgeOutset, y = -BadgeOutset) + .testTag("badge") + .semantics { contentDescription = badgeDescription } + ) + } + } +} diff --git a/app/src/main/java/com/example/research/common/ui/components/CollapsibleHeader.kt b/app/src/main/java/com/example/research/common/ui/components/CollapsibleHeader.kt index e7a8e44..c5978ff 100644 --- a/app/src/main/java/com/example/research/common/ui/components/CollapsibleHeader.kt +++ b/app/src/main/java/com/example/research/common/ui/components/CollapsibleHeader.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -29,7 +28,8 @@ fun CollapsibleHeader( onToggle: () -> Unit, modifier: Modifier = Modifier, subtitle: String? = null, - testTag: String? = null + testTag: String? = null, + badgeDescription: String? = null, ) { Row( modifier = modifier @@ -43,11 +43,11 @@ fun CollapsibleHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Icon( + BadgedIcon( painter = painterResource(iconRes), - contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + size = 24.dp, + badgeDescription = badgeDescription, ) Column( diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt index 80a2718..0dd9a9e 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt @@ -50,63 +50,36 @@ class DictionaryChecker( } } - suspend fun areDictionariesUpToDate( + suspend fun sourcesWithUpdates( folderUri: Uri, sources: List, files: List? = null - ): Boolean = withContext(Dispatchers.IO) { + ): Set = withContext(Dispatchers.IO) { try { - val enabledSources = sources.filter { it.isEnabled } - if (enabledSources.isEmpty()) { - return@withContext true - } - val currentDate = DateUtils.getCurrentDateString() val previousDate = DateUtils.getPreviousDateString() val dictFiles = getDictFiles(folderUri, files) - - val datedSources = enabledSources.filter { source -> - DictionarySource.hasDatePlaceholder(source.urlTemplate) - } - if (datedSources.isEmpty()) { - return@withContext true - } - - val allHaveToday = datedSources.all { source -> - dictFiles.hasFileForSource(source, currentDate) - } - - if (allHaveToday) { - return@withContext true - } - - val allHaveYesterday = datedSources.all { source -> - dictFiles.hasFileForSource(source, previousDate) - } - - if (allHaveYesterday) { - val serverHasToday = datedSources.any { source -> - isSourceAvailableOnServer(source, currentDate) + sources + .filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) } + .filter { source -> + when { + dictFiles.hasFileForSource(source, currentDate) -> false + dictFiles.hasFileForSource(source, previousDate) -> + isSourceAvailableOnServer(source, currentDate) + else -> when (probeSource(source, currentDate)) { + SourceAvailability.AVAILABLE -> true + SourceAvailability.NOT_FOUND -> + probeSource(source, previousDate) == SourceAvailability.AVAILABLE + SourceAvailability.UNKNOWN -> false + } + } } - - return@withContext !serverHasToday - } - - val serverCanOfferNewer = datedSources.any { source -> - when (probeSource(source, currentDate)) { - SourceAvailability.AVAILABLE -> true - SourceAvailability.NOT_FOUND -> - probeSource(source, previousDate) == SourceAvailability.AVAILABLE - SourceAvailability.UNKNOWN -> false - } - } - - return@withContext !serverCanOfferNewer + .mapTo(mutableSetOf(), DictionarySource::id) } catch (e: CancellationException) { throw e } catch (e: Exception) { Log.e(TAG, "Error checking currency: ${e.message}", e) - true + emptySet() } } diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt index 2e63450..e49d59d 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt @@ -34,14 +34,11 @@ class DictionaryRepository( private const val TAG = "DictionaryRepository" } - suspend fun areDictionariesUpToDate( - sources: List? = null - ): Boolean = withContext(Dispatchers.IO) { - val folderUri = preferencesManager.dictionaryPath - val uri = Uri.fromFile(File(folderUri)) - val files = fileStorageManager.listFilesInFolder(uri) - val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first() - checker.areDictionariesUpToDate(uri, sourcesToCheck, files) + suspend fun sourcesWithUpdates( + sources: List + ): Set = withContext(Dispatchers.IO) { + val uri = Uri.fromFile(File(preferencesManager.dictionaryPath)) + checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri)) } suspend fun getEnabledSources(sourceUrls: List? = null): List = diff --git a/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt index a9647c8..b80d3dd 100644 --- a/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/example/research/ui/settings/SettingsViewModel.kt @@ -117,7 +117,7 @@ class SettingsViewModel( dictionaryStatus.collect { status -> val persistedName = when (status) { DictionaryStatus.UpToDate -> "UpToDate" - DictionaryStatus.NeedsUpdate -> "NeedsUpdate" + is DictionaryStatus.NeedsUpdate -> "NeedsUpdate" DictionaryStatus.Empty -> "Empty" else -> null } @@ -365,8 +365,8 @@ class SettingsViewModel( return DictionaryStatus.UpToDate } - val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources) - return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate + val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources) + return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable) } catch (e: Exception) { android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e) if (localDictionaryRepository.dictionaries.value.isNotEmpty()) { @@ -625,11 +625,7 @@ class SettingsViewModel( val savedStatus = preferencesManager.dictionaryStatus() val savedStatusName = savedStatus?.substringBefore('|') val savedStatusDate = savedStatus?.substringAfter('|', "") - val restoredStatus = when (savedStatusName) { - "UpToDate" -> DictionaryStatus.UpToDate - "NeedsUpdate" -> DictionaryStatus.NeedsUpdate - else -> null - } + val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null if (restoredStatus != null && savedStatusDate == DateUtils.getCurrentDateString() && withContext(Dispatchers.IO) { installedDictionariesAreCurrent() } diff --git a/app/src/main/java/com/example/research/ui/settings/components/DictionaryListItem.kt b/app/src/main/java/com/example/research/ui/settings/components/DictionaryListItem.kt index fa9bdd8..08e710c 100644 --- a/app/src/main/java/com/example/research/ui/settings/components/DictionaryListItem.kt +++ b/app/src/main/java/com/example/research/ui/settings/components/DictionaryListItem.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.research.R +import com.example.research.common.ui.components.BadgedIcon import com.example.research.core.domain.model.Dictionary import kotlinx.coroutines.launch @@ -84,7 +85,8 @@ fun DictionaryListItem( onToggle: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, - isDeleteBlocked: () -> Boolean = { false } + isDeleteBlocked: () -> Boolean = { false }, + hasUpdate: Boolean = false, ) { val haptic = LocalHapticFeedback.current val density = LocalDensity.current @@ -195,11 +197,15 @@ fun DictionaryListItem( modifier = Modifier.size(IconButtonSize), contentAlignment = Alignment.Center ) { - Icon( + BadgedIcon( painter = painterResource(R.drawable.ic_book), - contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(IconSize) + size = IconSize, + badgeDescription = if (hasUpdate) { + stringResource(R.string.dictionary_update_available) + } else { + null + }, ) } diff --git a/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt b/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt index c41838b..60408fc 100644 --- a/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt +++ b/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt @@ -48,6 +48,7 @@ import com.example.research.common.ui.components.CollapsibleHeader import com.example.research.common.ui.components.EmptyState import com.example.research.common.ui.components.SectionCard import com.example.research.core.domain.model.Dictionary +import com.example.research.core.domain.usecase.DictionarySourceFileMatcher import com.example.research.core.domain.model.DictionarySource import com.example.research.feature.download.model.DownloadState import com.example.research.ui.settings.ImportState @@ -94,6 +95,21 @@ fun DictionaryManagement( derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting } } val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } } + val isUpdateAvailable = dictionaryStatus is DictionaryStatus.NeedsUpdate && + !isInProgress && hasEnabledSources && dictionaries.isNotEmpty() + val updatablePaths = remember(dictionaryStatus, sources, dictionaries, isUpdateAvailable) { + val ids = (dictionaryStatus as? DictionaryStatus.NeedsUpdate)?.sourceIds + if (!isUpdateAvailable || ids.isNullOrEmpty()) { + emptySet() + } else { + val updatableSources = sources.filter { it.id in ids } + dictionaries + .filter { dictionary -> + updatableSources.any { DictionarySourceFileMatcher.matches(it, dictionary) } + } + .mapTo(mutableSetOf(), Dictionary::path) + } + } // Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf, // snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read @@ -122,7 +138,12 @@ fun DictionaryManagement( isExpanded = isExpanded, onToggle = onToggleExpanded, subtitle = headerSubtitle, - testTag = "dictionary_header" + testTag = "dictionary_header", + badgeDescription = if (isUpdateAvailable && !isExpanded) { + stringResource(R.string.dictionaries_update_available) + } else { + null + }, ) AnimatedVisibility( @@ -182,7 +203,7 @@ fun DictionaryManagement( ) } } else { - if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) { + if (isUpdateAvailable) { DictionaryUpdateCard( onStartDownload = onStartDownload, modifier = backgroundModifier @@ -196,7 +217,8 @@ fun DictionaryManagement( modifier = Modifier .fillMaxWidth() .then(backgroundModifier), - isDeleteBlocked = isDeleteBlocked + isDeleteBlocked = isDeleteBlocked, + updatablePaths = updatablePaths, ) if (!isInProgress) { @@ -279,7 +301,8 @@ private fun DictionaryListSection( onToggleDictionary: (String) -> Unit, onDeleteDictionary: (Dictionary) -> Unit, modifier: Modifier = Modifier, - isDeleteBlocked: () -> Boolean = { false } + isDeleteBlocked: () -> Boolean = { false }, + updatablePaths: Set = emptySet(), ) { var appearedPaths by remember { mutableStateOf(emptySet()) } LaunchedEffect(dictionaries) { @@ -306,7 +329,8 @@ private fun DictionaryListSection( dictionary = dictionary, onToggle = { onToggleDictionary(dictionary.path) }, onDelete = { onDeleteDictionary(dictionary) }, - isDeleteBlocked = isDeleteBlocked + isDeleteBlocked = isDeleteBlocked, + hasUpdate = dictionary.path in updatablePaths, ) } } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6454402..b977072 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -87,6 +87,7 @@ URL содержит недопустимые символы Словари еще не загружены Доступны обновления словарей + Доступно обновление Отмена Добавить Добавить ещё URL diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 036097b..64ce44e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,7 @@ URL contains invalid characters No dictionaries downloaded yet Dictionary updates available + Update available Cancel Add Add another URL