From a5f3af83a456fb832d3544db83fcf3c83b1a5234 Mon Sep 17 00:00:00 2001 From: OneWay Date: Mon, 13 Jul 2026 19:20:25 +0800 Subject: [PATCH] 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. --- app/build.gradle.kts | 4 +- .../core/performance/ReSearchTrace.kt | 45 +++++++++++ .../research/common/ui/theme/Typography.kt | 6 ++ .../feature/download/DownloadManager.kt | 2 +- .../research/ui/article/ArticleScreen.kt | 3 +- .../research/ui/main/components/MainTopBar.kt | 78 ++++++++++--------- .../ui/main/components/SearchResultsList.kt | 3 +- .../research/ui/settings/SettingsViewModel.kt | 30 +++---- .../metadata/android/en-US/changelogs/3.txt | 3 + .../metadata/android/ru-RU/changelogs/3.txt | 3 + 10 files changed, 119 insertions(+), 58 deletions(-) create mode 100644 app/src/debug/java/com/example/research/core/performance/ReSearchTrace.kt create mode 100644 fastlane/metadata/android/en-US/changelogs/3.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/3.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7f4f101..8c72c16 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) { diff --git a/app/src/debug/java/com/example/research/core/performance/ReSearchTrace.kt b/app/src/debug/java/com/example/research/core/performance/ReSearchTrace.kt new file mode 100644 index 0000000..e5fd2c5 --- /dev/null +++ b/app/src/debug/java/com/example/research/core/performance/ReSearchTrace.kt @@ -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 section(name: String, block: () -> T): T { + if (!isAndroidRuntime) return block() + Trace.beginSection(name) + return try { + block() + } finally { + Trace.endSection() + } + } + + suspend fun 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) + } + } +} diff --git a/app/src/main/java/com/example/research/common/ui/theme/Typography.kt b/app/src/main/java/com/example/research/common/ui/theme/Typography.kt index 458b2e4..a7311d5 100644 --- a/app/src/main/java/com/example/research/common/ui/theme/Typography.kt +++ b/app/src/main/java/com/example/research/common/ui/theme/Typography.kt @@ -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) + ) diff --git a/app/src/main/java/com/example/research/feature/download/DownloadManager.kt b/app/src/main/java/com/example/research/feature/download/DownloadManager.kt index a001900..d32b5b7 100644 --- a/app/src/main/java/com/example/research/feature/download/DownloadManager.kt +++ b/app/src/main/java/com/example/research/feature/download/DownloadManager.kt @@ -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 } diff --git a/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt b/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt index 4ed36d2..ca62e7e 100644 --- a/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt +++ b/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt @@ -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, diff --git a/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt b/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt index e3a67fb..e8b703b 100644 --- a/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt +++ b/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt @@ -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)) } - } - } + } else { + 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 } + ) } diff --git a/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt b/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt index eadde4f..7da8297 100644 --- a/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt +++ b/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt @@ -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 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 99aaf85..20c00df 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 @@ -82,6 +82,8 @@ class SettingsViewModel( private val pendingSourceUrls = mutableSetOf() 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().stopService(stopIntent) } + } + + private fun refreshDictionaryStatus() { viewModelScope.launch { - val dictionaries = localDictionaryRepository.dictionaries.first() - dictionaryStatus.value = if (dictionaries.isNotEmpty()) { - DictionaryStatus.UpToDate - } else { - DictionaryStatus.Empty - } + dictionaryStatus.value = DictionaryStatus.Checking + dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() } } } diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt new file mode 100644 index 0000000..be387d0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3.txt @@ -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 diff --git a/fastlane/metadata/android/ru-RU/changelogs/3.txt b/fastlane/metadata/android/ru-RU/changelogs/3.txt new file mode 100644 index 0000000..585f70f --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/3.txt @@ -0,0 +1,3 @@ +- Исправлена карточка обновления словаря после запуска и отмены загрузки +- Исправлено зависание индикатора прогресса загрузки на 0% +- Строка поиска переведена на Material 3; выровнена типографика заголовков словаря