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
+114 -70

No files matched your search

@@ -7,7 +7,7 @@ sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus data object Checking : DictionaryStatus
data object UpToDate : DictionaryStatus data object UpToDate : DictionaryStatus
data object NeedsUpdate : DictionaryStatus data class NeedsUpdate(val sourceIds: Set<String>) : DictionaryStatus
data object Empty : DictionaryStatus data object Empty : DictionaryStatus
data class Error(val message: String) : 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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -29,7 +28,8 @@ fun CollapsibleHeader(
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
testTag: String? = null testTag: String? = null,
badgeDescription: String? = null,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -43,11 +43,11 @@ fun CollapsibleHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( BadgedIcon(
painter = painterResource(iconRes), painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp) size = 24.dp,
badgeDescription = badgeDescription,
) )
Column( Column(
@@ -50,63 +50,36 @@ class DictionaryChecker(
} }
} }
suspend fun areDictionariesUpToDate( suspend fun sourcesWithUpdates(
folderUri: Uri, folderUri: Uri,
sources: List<DictionarySource>, sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null files: List<DocumentFileInfo>? = null
): Boolean = withContext(Dispatchers.IO) { ): Set<String> = withContext(Dispatchers.IO) {
try { try {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
return@withContext true
}
val currentDate = DateUtils.getCurrentDateString() val currentDate = DateUtils.getCurrentDateString()
val previousDate = DateUtils.getPreviousDateString() val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files) val dictFiles = getDictFiles(folderUri, files)
sources
val datedSources = enabledSources.filter { source -> .filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) }
DictionarySource.hasDatePlaceholder(source.urlTemplate) .filter { source ->
} when {
if (datedSources.isEmpty()) { dictFiles.hasFileForSource(source, currentDate) -> false
return@withContext true dictFiles.hasFileForSource(source, previousDate) ->
}
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) isSourceAvailableOnServer(source, currentDate)
} else -> when (probeSource(source, currentDate)) {
return@withContext !serverHasToday
}
val serverCanOfferNewer = datedSources.any { source ->
when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND -> SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false SourceAvailability.UNKNOWN -> false
} }
} }
}
return@withContext !serverCanOfferNewer .mapTo(mutableSetOf(), DictionarySource::id)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e) Log.e(TAG, "Error checking currency: ${e.message}", e)
true emptySet()
} }
} }
@@ -34,14 +34,11 @@ class DictionaryRepository(
private const val TAG = "DictionaryRepository" private const val TAG = "DictionaryRepository"
} }
suspend fun areDictionariesUpToDate( suspend fun sourcesWithUpdates(
sources: List<DictionarySource>? = null sources: List<DictionarySource>
): Boolean = withContext(Dispatchers.IO) { ): Set<String> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath val uri = Uri.fromFile(File(preferencesManager.dictionaryPath))
val uri = Uri.fromFile(File(folderUri)) checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri))
val files = fileStorageManager.listFilesInFolder(uri)
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
} }
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> = suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
@@ -117,7 +117,7 @@ class SettingsViewModel(
dictionaryStatus.collect { status -> dictionaryStatus.collect { status ->
val persistedName = when (status) { val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate" DictionaryStatus.UpToDate -> "UpToDate"
DictionaryStatus.NeedsUpdate -> "NeedsUpdate" is DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty" DictionaryStatus.Empty -> "Empty"
else -> null else -> null
} }
@@ -365,8 +365,8 @@ class SettingsViewModel(
return DictionaryStatus.UpToDate return DictionaryStatus.UpToDate
} }
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources) val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources)
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e) android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) { if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
@@ -625,11 +625,7 @@ class SettingsViewModel(
val savedStatus = preferencesManager.dictionaryStatus() val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|') val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "") val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = when (savedStatusName) { val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null
"UpToDate" -> DictionaryStatus.UpToDate
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
else -> null
}
if (restoredStatus != null && if (restoredStatus != null &&
savedStatusDate == DateUtils.getCurrentDateString() && savedStatusDate == DateUtils.getCurrentDateString() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() } 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.components.BadgedIcon
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -84,7 +85,8 @@ fun DictionaryListItem(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false } isDeleteBlocked: () -> Boolean = { false },
hasUpdate: Boolean = false,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -195,11 +197,15 @@ fun DictionaryListItem(
modifier = Modifier.size(IconButtonSize), modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( BadgedIcon(
painter = painterResource(R.drawable.ic_book), painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, 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.EmptyState
import com.example.research.common.ui.components.SectionCard import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary 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.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState import com.example.research.ui.settings.ImportState
@@ -94,6 +95,21 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting } derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
} }
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } } 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, // 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 // snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
@@ -122,7 +138,12 @@ fun DictionaryManagement(
isExpanded = isExpanded, isExpanded = isExpanded,
onToggle = onToggleExpanded, onToggle = onToggleExpanded,
subtitle = headerSubtitle, subtitle = headerSubtitle,
testTag = "dictionary_header" testTag = "dictionary_header",
badgeDescription = if (isUpdateAvailable && !isExpanded) {
stringResource(R.string.dictionaries_update_available)
} else {
null
},
) )
AnimatedVisibility( AnimatedVisibility(
@@ -182,7 +203,7 @@ fun DictionaryManagement(
) )
} }
} else { } else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) { if (isUpdateAvailable) {
DictionaryUpdateCard( DictionaryUpdateCard(
onStartDownload = onStartDownload, onStartDownload = onStartDownload,
modifier = backgroundModifier modifier = backgroundModifier
@@ -196,7 +217,8 @@ fun DictionaryManagement(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(backgroundModifier), .then(backgroundModifier),
isDeleteBlocked = isDeleteBlocked isDeleteBlocked = isDeleteBlocked,
updatablePaths = updatablePaths,
) )
if (!isInProgress) { if (!isInProgress) {
@@ -279,7 +301,8 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit, onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit, onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false } isDeleteBlocked: () -> Boolean = { false },
updatablePaths: Set<String> = emptySet(),
) { ) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) } var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) { LaunchedEffect(dictionaries) {
@@ -306,7 +329,8 @@ private fun DictionaryListSection(
dictionary = dictionary, dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) }, onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) }, 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="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
<string name="dictionaries_not_downloaded">Словари еще не загружены</string> <string name="dictionaries_not_downloaded">Словари еще не загружены</string>
<string name="dictionaries_update_available">Доступны обновления словарей</string> <string name="dictionaries_update_available">Доступны обновления словарей</string>
<string name="dictionary_update_available">Доступно обновление</string>
<string name="action_cancel">Отмена</string> <string name="action_cancel">Отмена</string>
<string name="action_add">Добавить</string> <string name="action_add">Добавить</string>
<string name="action_add_another_url">Добавить ещё URL</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="dictionary_source_suspicious_characters">URL contains invalid characters</string>
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string> <string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
<string name="dictionaries_update_available">Dictionary updates available</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_cancel">Cancel</string>
<string name="action_add">Add</string> <string name="action_add">Add</string>
<string name="action_add_another_url">Add another URL</string> <string name="action_add_another_url">Add another URL</string>