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:
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(
|
||||
|
||||
+18
-45
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-8
@@ -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() }
|
||||
|
||||
+10
-4
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+29
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user