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.
This commit is contained in:
OneWay committed 2026-10-12 15:38:55 +08:00
1 parent c9d1a3439c
commit 911e8b61f6
10 files changed
+120 -76

No files matched your search

@@ -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<String>) : DictionaryStatus
data object Empty : DictionaryStatus
data class Error(val message: String) : DictionaryStatus
}
@@ -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 }
)
}
}
}
@@ -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(
@@ -50,63 +50,36 @@ class DictionaryChecker(
}
}
suspend fun areDictionariesUpToDate(
suspend fun sourcesWithUpdates(
folderUri: Uri,
sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null
): Boolean = withContext(Dispatchers.IO) {
): Set<String> = 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()
}
}
@@ -34,14 +34,11 @@ class DictionaryRepository(
private const val TAG = "DictionaryRepository"
}
suspend fun areDictionariesUpToDate(
sources: List<DictionarySource>? = 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<DictionarySource>
): Set<String> = withContext(Dispatchers.IO) {
val uri = Uri.fromFile(File(preferencesManager.dictionaryPath))
checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri))
}
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
@@ -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() }
@@ -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
},
)
}
@@ -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<String> = emptySet(),
) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
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,
)
}
}
+1
View File
@@ -87,6 +87,7 @@
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
<string name="dictionaries_not_downloaded">Словари еще не загружены</string>
<string name="dictionaries_update_available">Доступны обновления словарей</string>
<string name="dictionary_update_available">Доступно обновление</string>
<string name="action_cancel">Отмена</string>
<string name="action_add">Добавить</string>
<string name="action_add_another_url">Добавить ещё URL</string>
+1
View File
@@ -85,6 +85,7 @@
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
<string name="dictionaries_update_available">Dictionary updates available</string>
<string name="dictionary_update_available">Update available</string>
<string name="action_cancel">Cancel</string>
<string name="action_add">Add</string>
<string name="action_add_another_url">Add another URL</string>