Compare commits
4
Commits
1.0
..
a5f3af83a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f3af83a4 | ||
|
|
db0e8ba6ea | ||
|
|
4c2faf3c94 | ||
|
|
69a5ff6304 |
@@ -92,8 +92,8 @@ android {
|
|||||||
applicationId = "com.example.research"
|
applicationId = "com.example.research"
|
||||||
minSdk = project.property("minSdk").toString().toInt()
|
minSdk = project.property("minSdk").toString().toInt()
|
||||||
targetSdk = project.property("targetSdk").toString().toInt()
|
targetSdk = project.property("targetSdk").toString().toInt()
|
||||||
versionCode = 1
|
versionCode = 3
|
||||||
versionName = "1.0"
|
versionName = "1.1.1"
|
||||||
|
|
||||||
val isFdroid = project.hasProperty("fdroid")
|
val isFdroid = project.hasProperty("fdroid")
|
||||||
if (isFdroid) {
|
if (isFdroid) {
|
||||||
@@ -153,7 +153,7 @@ android {
|
|||||||
|
|
||||||
aboutLibraries {
|
aboutLibraries {
|
||||||
collect {
|
collect {
|
||||||
fetchRemoteLicense = true
|
fetchRemoteLicense = false
|
||||||
fetchRemoteFunding = false
|
fetchRemoteFunding = false
|
||||||
configPath = file("config")
|
configPath = file("config")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,3 +32,41 @@ HSPLcom/example/research/ui/article/ArticleState**->**(**)**
|
|||||||
# About screen (bonus optimization)
|
# About screen (bonus optimization)
|
||||||
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)**
|
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)**
|
||||||
|
|
||||||
|
# Updated 2026-07-11: cover the settings/dictionary flow and the Compose framework.
|
||||||
|
|
||||||
|
# Settings + dictionary management flow
|
||||||
|
HSPLcom/example/research/ui/settings/**->**(**)**
|
||||||
|
Lcom/example/research/ui/settings/**
|
||||||
|
HSPLcom/example/research/common/ui/components/**->**(**)**
|
||||||
|
Lcom/example/research/common/ui/components/**
|
||||||
|
HSPLcom/example/research/common/progress/**->**(**)**
|
||||||
|
Lcom/example/research/common/progress/**
|
||||||
|
HSPLcom/example/research/feature/download/**->**(**)**
|
||||||
|
Lcom/example/research/feature/download/**
|
||||||
|
|
||||||
|
# Pre-verify the app classes already covered by method rules above
|
||||||
|
Lcom/example/research/**
|
||||||
|
|
||||||
|
# Compose framework: the alpha AARs ship near-empty baseline profiles, so first
|
||||||
|
# compositions run interpreted without these rules.
|
||||||
|
HSPLandroidx/compose/runtime/**->**(**)**
|
||||||
|
Landroidx/compose/runtime/**
|
||||||
|
HSPLandroidx/compose/ui/**->**(**)**
|
||||||
|
Landroidx/compose/ui/**
|
||||||
|
HSPLandroidx/compose/foundation/**->**(**)**
|
||||||
|
Landroidx/compose/foundation/**
|
||||||
|
HSPLandroidx/compose/material3/**->**(**)**
|
||||||
|
Landroidx/compose/material3/**
|
||||||
|
HSPLandroidx/compose/animation/**->**(**)**
|
||||||
|
Landroidx/compose/animation/**
|
||||||
|
HSPLandroidx/compose/material/**->**(**)**
|
||||||
|
Landroidx/compose/material/**
|
||||||
|
|
||||||
|
# Support libraries on the same cold path
|
||||||
|
HSPLandroidx/lifecycle/**->**(**)**
|
||||||
|
Landroidx/lifecycle/**
|
||||||
|
HSPLandroidx/activity/compose/**->**(**)**
|
||||||
|
Landroidx/activity/compose/**
|
||||||
|
HSPLkotlinx/coroutines/**->**(**)**
|
||||||
|
Lkotlinx/coroutines/**
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.research.common.ui.theme
|
package com.example.research.common.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.PlatformTextStyle
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -85,3 +86,8 @@ val Typography = Typography(
|
|||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val Typography.dictionaryTitleLarge: TextStyle
|
||||||
|
get() = titleLarge.copy(
|
||||||
|
platformStyle = PlatformTextStyle(includeFontPadding = true)
|
||||||
|
)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class DownloadManager(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DownloadManager"
|
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 const val EXTRACT_PROGRESS_STEP_PERCENT = 5
|
||||||
private val TERMINAL_STATE_DURATION = 2.seconds
|
private val TERMINAL_STATE_DURATION = 2.seconds
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -94,7 +94,8 @@ class DictionaryChecker(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
response.isSuccessful
|
response.isSuccessful &&
|
||||||
|
response.header("Content-Type")?.startsWith("text/html") != true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
|
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
|
||||||
|
|||||||
+23
-1
@@ -33,6 +33,8 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
|
|
||||||
class HttpException(val statusCode: Int, message: String) : IOException(message)
|
class HttpException(val statusCode: Int, message: String) : IOException(message)
|
||||||
|
|
||||||
|
class InvalidPayloadException(message: String) : IOException(message)
|
||||||
|
|
||||||
class DictionaryDownloader(
|
class DictionaryDownloader(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val client: OkHttpClient,
|
private val client: OkHttpClient,
|
||||||
@@ -44,6 +46,9 @@ class DictionaryDownloader(
|
|||||||
private val RETRY_DELAY_BASE = 1.seconds
|
private val RETRY_DELAY_BASE = 1.seconds
|
||||||
private val MAX_RETRY_DELAY = 10.seconds
|
private val MAX_RETRY_DELAY = 10.seconds
|
||||||
private val FILE_CREATION_RETRY_DELAY = 200.milliseconds
|
private val FILE_CREATION_RETRY_DELAY = 200.milliseconds
|
||||||
|
|
||||||
|
private fun isGzipHeader(buffer: ByteArray, length: Int): Boolean =
|
||||||
|
length >= 2 && buffer[0] == 0x1f.toByte() && buffer[1] == 0x8b.toByte()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun deletePartialFileSafely(
|
private suspend fun deletePartialFileSafely(
|
||||||
@@ -139,7 +144,9 @@ class DictionaryDownloader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val error = currentResult.exceptionOrNull()
|
val error = currentResult.exceptionOrNull()
|
||||||
if (hasDatePlaceholder && error is HttpException && error.statusCode == 404) {
|
val currentBuildMissing = (error is HttpException && error.statusCode == 404) ||
|
||||||
|
error is InvalidPayloadException
|
||||||
|
if (hasDatePlaceholder && currentBuildMissing) {
|
||||||
val fallbackUrl = DictionarySource.buildUrl(source.urlTemplate, fallbackDate)
|
val fallbackUrl = DictionarySource.buildUrl(source.urlTemplate, fallbackDate)
|
||||||
val fallbackFileName = DictionarySource.extractFileName(source.urlTemplate, fallbackDate)
|
val fallbackFileName = DictionarySource.extractFileName(source.urlTemplate, fallbackDate)
|
||||||
return@withContext downloadFile(
|
return@withContext downloadFile(
|
||||||
@@ -234,6 +241,13 @@ class DictionaryDownloader(
|
|||||||
ensureActive()
|
ensureActive()
|
||||||
|
|
||||||
val body = response.body
|
val body = response.body
|
||||||
|
val expectGzip = fileName.lowercase().endsWith(DictionaryConfig.GZ_EXTENSION)
|
||||||
|
if (expectGzip && body.contentType()?.type == "text") {
|
||||||
|
response.close()
|
||||||
|
return@withContext Result.failure(
|
||||||
|
InvalidPayloadException(context.getString(R.string.download_file_not_found))
|
||||||
|
)
|
||||||
|
}
|
||||||
val contentLength = body.contentLength()
|
val contentLength = body.contentLength()
|
||||||
fileUri = fileStorageManager.createFileInFolder(folderUri, fileName, overwrite = true)
|
fileUri = fileStorageManager.createFileInFolder(folderUri, fileName, overwrite = true)
|
||||||
if (fileUri == null) {
|
if (fileUri == null) {
|
||||||
@@ -261,6 +275,9 @@ class DictionaryDownloader(
|
|||||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||||
ensureActive()
|
ensureActive()
|
||||||
|
|
||||||
|
if (expectGzip && totalBytesRead == 0L && bytesRead > 0 && !isGzipHeader(buffer, bytesRead)) {
|
||||||
|
throw InvalidPayloadException(context.getString(R.string.download_file_not_found))
|
||||||
|
}
|
||||||
output.write(buffer, 0, bytesRead)
|
output.write(buffer, 0, bytesRead)
|
||||||
totalBytesRead += bytesRead
|
totalBytesRead += bytesRead
|
||||||
if (contentLength > 0) {
|
if (contentLength > 0) {
|
||||||
@@ -301,6 +318,11 @@ class DictionaryDownloader(
|
|||||||
deletePartialFileSafely(fileUri, folderUri, fileName)
|
deletePartialFileSafely(fileUri, folderUri, fileName)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
if (e is InvalidPayloadException) {
|
||||||
|
deletePartialFileIfExists(folderUri, fileName)
|
||||||
|
deletePartialFileSafely(fileUri, folderUri, fileName)
|
||||||
|
return@withContext Result.failure(e)
|
||||||
|
}
|
||||||
if (e.isNetworkError() && currentRetry < maxRetries) {
|
if (e.isNetworkError() && currentRetry < maxRetries) {
|
||||||
deletePartialFileIfExists(folderUri, fileName)
|
deletePartialFileIfExists(folderUri, fileName)
|
||||||
deletePartialFileSafely(fileUri, folderUri, fileName)
|
deletePartialFileSafely(fileUri, folderUri, fileName)
|
||||||
|
|||||||
+8
-2
@@ -86,8 +86,14 @@ class DictionaryRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val allSources = preferencesManager.dictionarySources.first()
|
val allSources = preferencesManager.dictionarySources.first()
|
||||||
val normalizedSourceUrls = sourceUrls.map { DictionarySource.normalizeTemplate(it) }
|
val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
|
||||||
val sourcesToDownload = allSources.filter { it.urlTemplate in normalizedSourceUrls && it.isEnabled }
|
val sourcesToDownload = allSources.mapNotNull { source ->
|
||||||
|
if (!source.isEnabled || DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sourcesToDownload.isEmpty()) {
|
if (sourcesToDownload.isEmpty()) {
|
||||||
return@withContext Result.success(Unit)
|
return@withContext Result.success(Unit)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||||
import com.example.research.feature.search.SearchAction
|
import com.example.research.feature.search.SearchAction
|
||||||
import com.example.research.ui.theme.Spacing
|
import com.example.research.ui.theme.Spacing
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
@@ -258,7 +259,7 @@ fun ArticleTitleBar(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.dictionaryTitleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.example.research.ui.main.components
|
package com.example.research.ui.main.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
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.height
|
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.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -27,7 +28,7 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import com.example.research.ui.theme.AppWindowInsets
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.text.input.ImeAction
|
||||||
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.theme.dictionaryTitleLarge
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
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)
|
private val TopBarShape = RoundedCornerShape(28.dp)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -99,6 +102,7 @@ fun MainTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
private fun SearchField(
|
private fun SearchField(
|
||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
@@ -110,8 +114,6 @@ private fun SearchField(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
val contentColor = MaterialTheme.colorScheme.onSurface
|
|
||||||
val placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
|
|
||||||
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
|
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
|
||||||
|
|
||||||
@@ -123,57 +125,57 @@ private fun SearchField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
TextField(
|
||||||
modifier = modifier.height(TopBarHeight),
|
state = searchState,
|
||||||
color = containerColor,
|
enabled = enabled,
|
||||||
shape = TopBarShape
|
textStyle = MaterialTheme.typography.dictionaryTitleLarge,
|
||||||
) {
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
Row(
|
lineLimits = TextFieldLineLimits.SingleLine,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
leadingIcon = {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = searchIcon,
|
painter = searchIcon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = placeholderColor,
|
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
},
|
||||||
BasicTextField(
|
trailingIcon = if (searchState.text.isNotEmpty()) {
|
||||||
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()) {
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
searchState.edit { replace(0, length, "") }
|
searchState.edit { replace(0, length, "") }
|
||||||
onClearQuery()
|
onClearQuery()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
|
||||||
.testTag("clear_search")
|
.testTag("clear_search")
|
||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = closeIcon,
|
painter = closeIcon,
|
||||||
contentDescription = clearLabel,
|
contentDescription = clearLabel,
|
||||||
tint = placeholderColor,
|
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} 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.LoadState
|
||||||
import androidx.paging.compose.*
|
import androidx.paging.compose.*
|
||||||
import com.example.research.R
|
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.domain.model.IndexEntry
|
||||||
import com.example.research.core.util.removeDictionarySuffixes
|
import com.example.research.core.util.removeDictionarySuffixes
|
||||||
import com.example.research.ui.theme.Spacing
|
import com.example.research.ui.theme.Spacing
|
||||||
@@ -83,7 +84,7 @@ private fun SearchResultItem(
|
|||||||
showDictionaryName: Boolean = true
|
showDictionaryName: Boolean = true
|
||||||
) {
|
) {
|
||||||
// Cache text styles to avoid recreation on every composition
|
// 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 subtitleStyle = MaterialTheme.typography.bodySmall
|
||||||
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
|
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ class SettingsViewModel(
|
|||||||
private val pendingSourceUrls = mutableSetOf<String>()
|
private val pendingSourceUrls = mutableSetOf<String>()
|
||||||
private var isDownloadInProgress = false
|
private var isDownloadInProgress = false
|
||||||
|
|
||||||
|
private var cancelRefreshPending = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupStateObservation()
|
setupStateObservation()
|
||||||
initialize()
|
initialize()
|
||||||
@@ -143,6 +145,7 @@ class SettingsViewModel(
|
|||||||
when (operationState.downloadState) {
|
when (operationState.downloadState) {
|
||||||
is DownloadState.Loading, is DownloadState.Extracting -> {
|
is DownloadState.Loading, is DownloadState.Extracting -> {
|
||||||
isDownloadInProgress = true
|
isDownloadInProgress = true
|
||||||
|
cancelRefreshPending = false
|
||||||
}
|
}
|
||||||
is DownloadState.Success -> {
|
is DownloadState.Success -> {
|
||||||
pendingSourceUrls.clear()
|
pendingSourceUrls.clear()
|
||||||
@@ -178,11 +181,7 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
is DownloadState.Cancelled -> {
|
is DownloadState.Cancelled -> {
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
cancelRefreshPending = true
|
||||||
DictionaryStatus.Empty
|
|
||||||
} else {
|
|
||||||
DictionaryStatus.UpToDate
|
|
||||||
}
|
|
||||||
if (pendingSourceUrls.isNotEmpty()) {
|
if (pendingSourceUrls.isNotEmpty()) {
|
||||||
pendingSourceUrls.forEach { urlTemplate ->
|
pendingSourceUrls.forEach { urlTemplate ->
|
||||||
val source = dictionaryState.dictionarySources.find {
|
val source = dictionaryState.dictionarySources.find {
|
||||||
@@ -193,6 +192,12 @@ class SettingsViewModel(
|
|||||||
pendingSourceUrls.clear()
|
pendingSourceUrls.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is DownloadState.Idle -> {
|
||||||
|
if (cancelRefreshPending) {
|
||||||
|
cancelRefreshPending = false
|
||||||
|
refreshDictionaryStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,10 +412,6 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
|
||||||
dictionaryStatus.value = status
|
|
||||||
|
|
||||||
if (status !is DictionaryStatus.NeedsUpdate) return@launch
|
|
||||||
|
|
||||||
val installedSources = installedEnabledSources(
|
val installedSources = installedEnabledSources(
|
||||||
preferencesManager.dictionarySources.first(),
|
preferencesManager.dictionarySources.first(),
|
||||||
@@ -481,13 +482,12 @@ class SettingsViewModel(
|
|||||||
getApplication<Application>().stopService(stopIntent)
|
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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-13
@@ -29,13 +29,28 @@ fun DictionaryProgressSection(
|
|||||||
percent: Int,
|
percent: Int,
|
||||||
testTag: String,
|
testTag: String,
|
||||||
) {
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
val targetProgress = progressProvider().coerceIn(0f, 1f)
|
val targetProgress = progressProvider().coerceIn(0f, 1f)
|
||||||
var monotonicTargetProgress by remember { mutableFloatStateOf(0f) }
|
var monotonicTargetProgress by remember { mutableFloatStateOf(0f) }
|
||||||
|
var displayedLabel by remember { mutableStateOf(label) }
|
||||||
|
var displayedPercent by remember { mutableIntStateOf(percent) }
|
||||||
|
var displayedTestTag by remember { mutableStateOf(testTag) }
|
||||||
|
|
||||||
LaunchedEffect(visible, targetProgress) {
|
LaunchedEffect(visible, targetProgress) {
|
||||||
monotonicTargetProgress = if (visible) {
|
if (visible) {
|
||||||
maxOf(monotonicTargetProgress, targetProgress)
|
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
|
||||||
} else {
|
}
|
||||||
0f
|
}
|
||||||
|
SideEffect {
|
||||||
|
if (visible) {
|
||||||
|
displayedLabel = label
|
||||||
|
displayedPercent = percent
|
||||||
|
displayedTestTag = testTag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
@@ -44,24 +59,18 @@ fun DictionaryProgressSection(
|
|||||||
label = "dictionary-progress"
|
label = "dictionary-progress"
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
Column(modifier = Modifier.testTag(displayedTestTag)) {
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically(),
|
|
||||||
modifier = modifier.testTag(testTag)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = displayedLabel,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "$percent%",
|
text = "$displayedPercent%",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Dictionary updates: a missing daily build served as an HTML page no longer breaks the update; the app now falls back to the previous day's build
|
||||||
|
- Fixed update check reporting an update when none is available on the server
|
||||||
|
- Fixed source matching when a dictionary source was added with a dated URL
|
||||||
|
- Fixed the progress bar animating backwards when a download or indexing finishes
|
||||||
|
- Faster first opening of screens (ahead-of-time compilation profile now covers the whole UI)
|
||||||
@@ -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,5 @@
|
|||||||
|
- Обновление словарей: отсутствующая суточная сборка, отдаваемая как HTML-страница, больше не ломает обновление — приложение переключается на сборку за предыдущий день
|
||||||
|
- Исправлена проверка обновлений, сообщавшая об обновлении, которого нет на сервере
|
||||||
|
- Исправлено сопоставление источников, добавленных по URL с датой
|
||||||
|
- Исправлена обратная анимация индикатора прогресса при завершении загрузки и индексации
|
||||||
|
- Ускорено первое открытие экранов (AOT-профиль компиляции покрывает весь интерфейс)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Исправлена карточка обновления словаря после запуска и отмены загрузки
|
||||||
|
- Исправлено зависание индикатора прогресса загрузки на 0%
|
||||||
|
- Строка поиска переведена на Material 3; выровнена типографика заголовков словаря
|
||||||
@@ -40,10 +40,6 @@ kotlin.daemon.jvmargs=-Xmx3g -XX:+UseParallelGC
|
|||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
# Android build optimizations
|
|
||||||
org.gradle.configuration-cache=true
|
|
||||||
org.gradle.configuration-cache.problems=warn
|
|
||||||
|
|
||||||
# SDK Versions
|
# SDK Versions
|
||||||
compileSdk=37
|
compileSdk=37
|
||||||
minSdk=31
|
minSdk=31
|
||||||
|
|||||||
Reference in New Issue
Block a user