21 Commits
Author SHA1 Message Date
OneWay cab2dc742e fix: restore boundary progress publishes and animate the bar at constant velocity 2026-09-06 18:14:53 +08:00
OneWay 962605e2b4 fix: latch terminal notification state and post pipeline progress on one thread
A late ImportState Success snapshot collected after the success
notification overwrote it with an ongoing progress record. Post
progress on Main behind a volatile terminal latch; notifications go
through NotificationPort, the pipeline lives in the coordinator, and
the service finish is injected.
2026-09-06 17:05:46 +08:00
OneWay d360ecfb2b fix: defer import errors until the copy loop finishes
Errors and skips are collected while the copy loop runs and reported
once it ends. The final import state is exhaustive - success, already
added, invalid name, or nothing supported found - so every import run
reaches a terminal state and the pipeline notification always resolves
instead of lingering as an indeterminate progress bar.
2026-09-06 17:05:46 +08:00
OneWay 78754d2c75 fix: tie back arrow to pipeline idle state
Computed from operation states in SettingsViewModel instead of a fixed
delay; cancel and error release the arrow immediately.
2026-09-05 05:55:56 +08:00
OneWay 3567f50ad3 perf(about): parse aboutlibraries.json off the main thread
Bump AboutLibraries plugin to 15.0.4 -> 15.2.0; parse
aboutlibraries.json on Dispatchers.IO via produceState (header renders
immediately, rows fill in); drop animateContentSize from rows.
2026-09-05 00:37:41 +08:00
OneWay c79be491cc fix: open Settings immediately on cold start when no dictionaries
Seed and synchronously read hadNoDictionaries before setContent so
Settings shows from the first frame until the startup scan confirms.
2026-09-04 19:09:19 +08:00
OneWay 0f3d4b965c fix: key search result rows by dictionary, offset and word
Paging result rows had no keys and were matched by position: appended
pages and query diffs could not reuse row state. Keys derived from
dictionaryPath+offset+word are stable per row, and the paging source
collapses duplicate entries for one dictionary card: case and diacritic
alias variants of a headword share the normalized word, so entries are
deduplicated by dictionaryPath+word+offset before emitting.
2026-09-05 03:00:44 +08:00
OneWay e6f9f7f900 fix: hide dictionary rows from accessibility while mutations blocked
invisibleToUser() removes blocked content from the accessibility tree;
mergeDescendants only regrouped traversal order.
2026-09-04 18:04:32 +08:00
OneWay e5650f7247 fix: re-check mutation-block inside gesture coroutines
Stale drag-tick coroutines launched before the flip no-op at execution
time instead of moving the row after the reset.
2026-09-04 13:28:27 +08:00
OneWay ccbd037507 refactor: unify swipe reset to snap before clearing offset
Snap the Animatable first, clear rawOffset after - no frame with the
row still translated while the delete affordance is hidden.
2026-09-04 02:05:59 +08:00
OneWay 1907e3004d fix: key dictionary list items by path
Swipe state follows its row instead of migrating to a neighbor on
delete or reorder.
2026-09-04 01:53:06 +08:00
OneWay 84800cf2b0 refactor: isolate dictionary rows from mutation-block recomposition
Delete-block flag becomes a () -> Boolean provider read at state/gesture
time, so flipping it no longer recomposes the whole Dictionaries block.
2026-09-04 14:59:59 +08:00
OneWay 33cc386015 refactor: replace deprecated currentWindowAdaptiveInfo with V2 2026-09-02 16:18:39 +08:00
OneWay 07031c7ac1 refactor: expose state flows via asStateFlow
Drops the explicit getters and an unused import, and lets an emptyList
type argument be inferred.
2026-09-02 12:55:48 +08:00
OneWay 5a7ba69b28 chore: remove unused index comparison and decode helpers
IndexSearcher.compareEntryGroups / IndexComparisonSummary /
IndexComparisonException / readEntryOrNull and AboutLibrariesData.decode
have no call sites left in the app.
2026-09-02 11:30:51 +08:00
OneWay bfe4721c6b fix: reset dictionary progress bar between indexing operations
monotonicTargetProgress is held in remember and only ever grows via
maxOf, so when a new indexing starts after a previous one reached a
higher value the bar is left at the old level while the percent label
starts at 0. Snap it to the current target when the section becomes
visible so the bar follows the new operation from its first frame.
2026-09-02 10:56:32 +08:00
OneWay 097bb23ce9 fix: cancel stale progress notification on process start
Notifications belong to the package and outlive the process: after the
system kills the foreground service mid-download the frozen progress
notification stays in the shade forever. A fresh process means no
service is running, so any leftover notification is stale.
2026-09-02 08:37:43 +08:00
OneWay 5126c19f3c perf: mark Compose state types as @Immutable
Compose compiler reports showed DownloadState's sealed root as Uncertain
and DownloadProgressState/SettingsUiState as runtime stability, so
skippability checks were deferred to runtime. The annotations make them
compile-time stable and turn any future mutable field into an explicit
contract break.
2026-09-01 17:28:17 +08:00
OneWay 70faf71f99 chore: drop the unused indexing_label string 2026-09-01 13:36:23 +08:00
OneWay 9fcd7914dc refactor: slim IndexingProgress to a producer-supplied [0,1] progress
perFileProgress was never populated and its progress branch was
unreachable; currentFile, currentIndex, currentFileProgress and label
were write-only (no consumer read them); progressPercent had no
callers; the -1f aggregate sentinel is gone along with the fallback
branches that only fired when the tracker was absent.
2026-09-01 13:36:23 +08:00
OneWay bd7f597986 fix: report Idle instead of fake Success when nothing is downloaded
The early return skipped the terminal-state delay and reset, leaving
a phantom 100% Success (and a stuck "indexing" bar plus a success
notification) for downloads that never happened.
2026-09-05 10:03:54 +08:00
5 changed files with 50 additions and 73 deletions
@@ -1,45 +1,16 @@
package com.example.research.core.domain.model
import kotlin.math.roundToInt
import androidx.compose.runtime.Immutable
/**
* Snapshot of an ongoing indexing operation.
*
* [progress] is the authoritative [0f, 1f] completion value and is O(1) to
* compute: the repository's internal `ProgressTracker` maintains a running
* sum across files incrementally and stores it in [aggregateSum], avoiding
* an O(n) walk over [perFileProgress] on every read.
* [perFileProgress] is kept for diagnostics; callers should prefer
* [progress] / [progressPercent].
* [progress] is the authoritative [0f, 1f] completion value: the repository
* aggregates a file-size-weighted sum across files incrementally while
* indexing, so reading it is O(1).
*/
@Immutable
data class IndexingProgress(
val currentFile: String = "",
val currentIndex: Int = 0,
val totalFiles: Int = 0,
val isIndexing: Boolean = false,
val currentFileProgress: Float = 0f,
val label: String = "",
val perFileProgress: Map<String, Float> = emptyMap(),
/** Pre-aggregated sum in [0f, totalFiles] supplied by the producer; -1f = unknown. */
val aggregateSum: Float = -1f,
) {
val progress: Float
get() = if (totalFiles > 0) {
when {
aggregateSum >= 0f -> (aggregateSum / totalFiles).coerceIn(0f, 1f)
perFileProgress.isNotEmpty() -> {
val totalProgress = perFileProgress.values.sum()
(totalProgress / totalFiles).coerceIn(0f, 1f)
}
else -> {
val completedFiles = (currentIndex - 1).coerceAtLeast(0)
((completedFiles + currentFileProgress) / totalFiles).coerceIn(0f, 1f)
}
}
} else 0f
val progressPercent: Int
get() = (progress * 100).roundToInt().coerceIn(0, 100)
}
val progress: Float = 0f,
)
@@ -70,13 +70,9 @@ private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFi
aggregateWeightedMicros.addAndGet(newMicros - prevMicros)
}
/**
* Current weighted sum mapped back to [0f, totalFiles] so the existing
* IndexingProgress model can keep deriving progress as aggregate/total.
*/
fun aggregateSum(): Float {
fun progress(): Float {
val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0)
return (weightedProgress.coerceIn(0.0, 1.0) * totalFiles).toFloat()
return weightedProgress.coerceIn(0.0, 1.0).toFloat()
}
fun reset() {
@@ -192,61 +188,59 @@ class LocalDictionaryRepository(
mutableIndexingProgress.emit(
IndexingProgress(
isIndexing = true,
currentFile = context.getString(R.string.indexing_label),
totalFiles = filesToScan.size,
currentIndex = filesToScan.size - filesToIndex.size,
aggregateSum = progressTracker?.aggregateSum() ?: -1f,
progress = progressTracker?.progress() ?: 0f,
)
)
val lastEmittedPercent = AtomicInteger(-1)
val lastProgressEmitMs = AtomicLong(0L)
val publishCurrentProgress = {
val tracker = progressTracker
if (tracker != null) {
val progress = tracker.progress()
lastEmittedPercent.set((progress * 100f).toInt().coerceIn(0, 100))
lastProgressEmitMs.set(SystemClock.elapsedRealtime())
mutableIndexingProgress.update { p ->
p.copy(progress = progress)
}
}
}
supervisorScope {
filesToIndex.mapIndexed { index, file ->
filesToIndex.map { file ->
async {
indexingSemaphore.withPermit {
coroutineContext.ensureActive()
yield()
val res = indexDictionary(file) { _, op, prog ->
val res = indexDictionary(file) { _, _, prog ->
if (prog >= 0f) {
progressTracker?.updateFileProgress(file.name, prog)
}
val tracker = progressTracker
val aggregate = tracker?.aggregateSum() ?: -1f
val newPercent = if (filesToScan.isNotEmpty() && aggregate >= 0f) {
((aggregate / filesToScan.size) * 100f).toInt().coerceIn(0, 100)
} else -1
val progress = progressTracker?.progress() ?: 0f
val newPercent = (progress * 100f).toInt().coerceIn(0, 100)
val currentIdx = filesToScan.size - filesToIndex.size + index + 1
val previous = mutableIndexingProgress.value
val now = SystemClock.elapsedRealtime()
val percentChanged = newPercent >= 0 && newPercent != lastEmittedPercent.get()
val fileChanged = previous.currentFile != file.name
val percentChanged = newPercent != lastEmittedPercent.get()
val completed = newPercent >= 100 || prog >= 1f
val intervalElapsed =
now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS
val shouldEmit = fileChanged || completed || (percentChanged && intervalElapsed)
val shouldEmit = completed || (percentChanged && intervalElapsed)
if (shouldEmit) {
if (newPercent >= 0) lastEmittedPercent.set(newPercent)
lastEmittedPercent.set(newPercent)
lastProgressEmitMs.set(now)
mutableIndexingProgress.update { p ->
p.copy(
currentFile = file.name,
currentIndex = currentIdx,
currentFileProgress = prog,
label = op.ifEmpty { p.label },
aggregateSum = aggregate,
)
p.copy(progress = progress)
}
}
}
if (res is OperationResult.Success) {
progressTracker?.updateFileProgress(file.name, 1.0f)
publishCurrentProgress()
totalArticlesIndexed.addAndGet(res.data.articleCount)
val dictionary = res.data
addDictionary(dictionary)
@@ -1,8 +1,8 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -19,6 +19,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
private const val MILLIS_PER_PROGRESS_UNIT = 10_000
private const val MIN_PROGRESS_MOTION_MS = 50
private const val MAX_PROGRESS_MOTION_MS = 650
@Composable
fun DictionaryProgressSection(
visible: Boolean,
@@ -58,11 +62,21 @@ fun DictionaryProgressSection(
displayedTestTag = testTag
}
}
val animatedProgress by animateFloatAsState(
val animatedState = remember { Animatable(0f) }
LaunchedEffect(monotonicTargetProgress) {
val delta = monotonicTargetProgress - animatedState.value
if (delta > 0f) {
animatedState.animateTo(
targetValue = monotonicTargetProgress,
animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing),
label = "dictionary-progress"
animationSpec = tween(
durationMillis = (delta * MILLIS_PER_PROGRESS_UNIT).toInt()
.coerceIn(MIN_PROGRESS_MOTION_MS, MAX_PROGRESS_MOTION_MS),
easing = LinearEasing,
),
)
}
}
val animatedProgress = animatedState.value
Column(modifier = Modifier.testTag(displayedTestTag)) {
Row(
-1
View File
@@ -12,7 +12,6 @@
<string name="language_english">Английский</string>
<string name="language_russian">Русский</string>
<string name="language_system">Системный</string>
<string name="indexing_label">Индексация словарей</string>
<string name="clear_search">Очистить поиск</string>
<string name="article_no_selected">Статья не выбрана</string>
<string name="article_return_to_search">Вернуться к поиску</string>
-1
View File
@@ -12,7 +12,6 @@
<string name="language_english">English</string>
<string name="language_russian">Russian</string>
<string name="language_system">System</string>
<string name="indexing_label">Indexing dictionaries</string>
<string name="clear_search">Clear search</string>
<string name="article_no_selected">No article selected</string>
<string name="article_return_to_search">Return to search</string>