Fix dictionary update flow and rebuild the search field on Material 3

Fixed the update card reappearing for a moment after tapping it: the tap
re-ran the status check and republished NeedsUpdate before the download
service reported Loading, so the card flashed back in that gap.

Fixed the update card never returning after cancelling a download: both the
Cancelled branch and cancelDownload() forced the status to UpToDate, and the
card's tap is the only thing that re-evaluates it. The status is now
re-derived on the Cancelled to Idle transition, once the partially downloaded
archive - which carries today's date and would otherwise be read as a current
dictionary - has been cleaned up.

Fixed the download progress bar staying at 0% far too long: progress was
published in 3-percentage-point buckets and then scaled by the download stage
weight, so the bar only moved after about 7% of the archive had transferred.

Rebuilt the search field on the Material 3 TextField instead of a hand-rolled
BasicTextField inside a Surface, so it picks up the standard container, cursor
and icon treatment. Dictionary entry titles in the search field, the results
list and the article bar now share a dictionaryTitleLarge style that keeps the
font padding, so their line height lines up with the Material 3 text field.
This commit is contained in:
2026-07-13 19:20:25 +08:00
parent db0e8ba6ea
commit a5f3af83a4
10 changed files with 119 additions and 58 deletions
+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 = 2
versionName = "1.1.0"
versionCode = 3
versionName = "1.1.1"
val isFdroid = project.hasProperty("fdroid")
if (isFdroid) {
@@ -0,0 +1,45 @@
package com.example.research.core.performance
import android.os.Trace
import java.util.concurrent.atomic.AtomicInteger
object ReSearchTrace {
const val SEARCH_PAGING = "ReSearch/Search/Paging"
const val SEARCH_DIRECT = "ReSearch/Search/Direct"
const val ARTICLE_PARSE = "ReSearch/Article/Parse"
const val ARTICLE_READ = "ReSearch/Article/Read"
const val ARTICLE_PRE_MEASURE = "ReSearch/Article/PreMeasure"
const val DICTIONARY_DOWNLOAD = "ReSearch/Dictionary/Download"
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import"
const val FLOW_UPDATE_CARD_APPEAR = "ReSearch/Flow/UpdateCardAppear"
const val FLOW_UPDATING = "ReSearch/Flow/Updating"
const val FLOW_PROGRESS_DISAPPEAR = "ReSearch/Flow/ProgressDisappear"
private val nextCookie = AtomicInteger()
@PublishedApi
internal val isAndroidRuntime = System.getProperty("java.vm.name") == "Dalvik"
inline fun <T> section(name: String, block: () -> T): T {
if (!isAndroidRuntime) return block()
Trace.beginSection(name)
return try {
block()
} finally {
Trace.endSection()
}
}
suspend fun <T> asyncSection(name: String, block: suspend () -> T): T {
if (!isAndroidRuntime) return block()
val cookie = nextCookie.incrementAndGet()
Trace.beginAsyncSection(name, cookie)
return try {
block()
} finally {
Trace.endAsyncSection(name, cookie)
}
}
}
@@ -1,6 +1,7 @@
package com.example.research.common.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -85,3 +86,8 @@ val Typography = Typography(
letterSpacing = 0.5.sp
)
)
val Typography.dictionaryTitleLarge: TextStyle
get() = titleLarge.copy(
platformStyle = PlatformTextStyle(includeFontPadding = true)
)
@@ -27,7 +27,7 @@ class DownloadManager(
companion object {
private const val TAG = "DownloadManager"
private const val DOWNLOAD_PROGRESS_STEP_PERCENT = 3
private const val DOWNLOAD_PROGRESS_STEP_PERCENT = 1
private const val EXTRACT_PROGRESS_STEP_PERCENT = 5
private val TERMINAL_STATE_DURATION = 2.seconds
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.feature.search.SearchAction
import com.example.research.ui.theme.Spacing
import com.example.research.ui.theme.AppWindowInsets
@@ -258,7 +259,7 @@ fun ArticleTitleBar(
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1,7 +1,6 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -9,16 +8,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -27,7 +28,7 @@ import androidx.compose.runtime.snapshotFlow
import com.example.research.ui.theme.AppWindowInsets
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -36,9 +37,11 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import kotlinx.coroutines.flow.distinctUntilChanged
import com.example.research.ui.theme.Spacing
private val TopBarHeight = 56.dp
private val TopBarHeight = Spacing.topBarHeight
private val TopBarShape = RoundedCornerShape(28.dp)
@Composable
@@ -99,6 +102,7 @@ fun MainTopBar(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchField(
searchState: TextFieldState,
enabled: Boolean,
@@ -110,8 +114,6 @@ private fun SearchField(
modifier: Modifier = Modifier
) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
val contentColor = MaterialTheme.colorScheme.onSurface
val placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
@@ -123,57 +125,57 @@ private fun SearchField(
}
}
Surface(
modifier = modifier.height(TopBarHeight),
color = containerColor,
shape = TopBarShape
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
state = searchState,
enabled = enabled,
textStyle = MaterialTheme.typography.dictionaryTitleLarge,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
lineLimits = TextFieldLineLimits.SingleLine,
leadingIcon = {
Icon(
painter = searchIcon,
contentDescription = null,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
BasicTextField(
state = searchState,
enabled = enabled,
textStyle = MaterialTheme.typography.titleLarge.copy(color = contentColor),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
lineLimits = TextFieldLineLimits.SingleLine,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.testTag("search_field")
.semantics { testTagsAsResourceId = true }
)
if (searchState.text.isNotEmpty()) {
},
trailingIcon = if (searchState.text.isNotEmpty()) {
{
IconButton(
onClick = {
searchState.edit { replace(0, length, "") }
onClearQuery()
},
modifier = Modifier
.size(40.dp)
.testTag("clear_search")
.semantics { testTagsAsResourceId = true }
) {
Icon(
painter = closeIcon,
contentDescription = clearLabel,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
}
}
} else {
Box(modifier = Modifier.size(40.dp))
}
}
}
null
},
shape = TopBarShape,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(
top = 8.dp,
bottom = 8.dp
),
colors = TextFieldDefaults.colors(
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
disabledContainerColor = containerColor,
cursorColor = MaterialTheme.colorScheme.primary,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier = modifier
.height(TopBarHeight)
.testTag("search_field")
.semantics { testTagsAsResourceId = true }
)
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.*
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes
import com.example.research.ui.theme.Spacing
@@ -83,7 +84,7 @@ private fun SearchResultItem(
showDictionaryName: Boolean = true
) {
// Cache text styles to avoid recreation on every composition
val titleStyle = MaterialTheme.typography.titleLarge
val titleStyle = MaterialTheme.typography.dictionaryTitleLarge
val subtitleStyle = MaterialTheme.typography.bodySmall
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
@@ -82,6 +82,8 @@ class SettingsViewModel(
private val pendingSourceUrls = mutableSetOf<String>()
private var isDownloadInProgress = false
private var cancelRefreshPending = false
init {
setupStateObservation()
initialize()
@@ -143,6 +145,7 @@ class SettingsViewModel(
when (operationState.downloadState) {
is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true
cancelRefreshPending = false
}
is DownloadState.Success -> {
pendingSourceUrls.clear()
@@ -178,11 +181,7 @@ class SettingsViewModel(
}
is DownloadState.Cancelled -> {
isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
}
cancelRefreshPending = true
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
@@ -193,6 +192,12 @@ class SettingsViewModel(
pendingSourceUrls.clear()
}
}
is DownloadState.Idle -> {
if (cancelRefreshPending) {
cancelRefreshPending = false
refreshDictionaryStatus()
}
}
else -> {}
}
@@ -407,10 +412,6 @@ class SettingsViewModel(
}
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
if (status !is DictionaryStatus.NeedsUpdate) return@launch
val installedSources = installedEnabledSources(
preferencesManager.dictionarySources.first(),
@@ -481,13 +482,12 @@ class SettingsViewModel(
getApplication<Application>().stopService(stopIntent)
}
viewModelScope.launch {
val dictionaries = localDictionaryRepository.dictionaries.first()
dictionaryStatus.value = if (dictionaries.isNotEmpty()) {
DictionaryStatus.UpToDate
} else {
DictionaryStatus.Empty
}
private fun refreshDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.value = DictionaryStatus.Checking
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
}
}
@@ -0,0 +1,3 @@
- Fixed the dictionary update card after starting or cancelling a download
- Fixed download progress staying at 0% for too long
- Rebuilt the search field on Material 3 and aligned dictionary title typography
@@ -0,0 +1,3 @@
- Исправлена карточка обновления словаря после запуска и отмены загрузки
- Исправлено зависание индикатора прогресса загрузки на 0%
- Строка поиска переведена на Material 3; выровнена типографика заголовков словаря