Skip to content

Commit 79d16a6

Browse files
committed
feat: add ViewModel progress infrastructure
Introduce a common pattern for progress notifications in ViewModels, decoupling progress dialog management from Activity/Fragment context.
1 parent 22dafd8 commit 79d16a6

7 files changed

Lines changed: 794 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.progress
17+
18+
import com.ichi2.anki.ProgressContext
19+
import com.ichi2.anki.withProgress
20+
import kotlinx.coroutines.CoroutineScope
21+
import net.ankiweb.rsdroid.Backend
22+
23+
/**
24+
* Bridges the backend progress polling system into [ProgressScope].
25+
*
26+
* @param backend the Anki backend instance to poll for progress
27+
* @param extractProgress lambda to extract progress data from the backend
28+
* @param block the operation to execute
29+
*/
30+
suspend fun <T> ProgressScope.withBackendProgress(
31+
backend: Backend,
32+
progressContext: ProgressContext = ProgressContext(),
33+
extractProgress: ProgressContext.() -> Unit,
34+
block: suspend CoroutineScope.() -> T,
35+
): T =
36+
backend.withProgress(
37+
progressContext = progressContext,
38+
extractProgress = extractProgress,
39+
updateUi = {
40+
updateProgress(
41+
message = text,
42+
amount = amount,
43+
)
44+
},
45+
block = block,
46+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.progress
17+
18+
/**
19+
* Interface for ViewModels that expose progress state to the UI.
20+
*/
21+
interface HasProgress {
22+
val progressManager: ProgressManager
23+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.progress
17+
18+
import com.ichi2.anki.ProgressContext
19+
import kotlinx.coroutines.flow.MutableStateFlow
20+
import kotlinx.coroutines.flow.StateFlow
21+
import java.util.concurrent.atomic.AtomicLong
22+
23+
/**
24+
* Manages progress state for ViewModel operations.
25+
*
26+
* Multiple [withProgress] calls may be active at the same time. Rather than
27+
* tracking a single "current" operation, every active call is stored by id so
28+
* that state is well-defined regardless of start/completion order:
29+
*/
30+
class ProgressManager {
31+
val progress: StateFlow<ViewModelProgress>
32+
field = MutableStateFlow<ViewModelProgress>(ViewModelProgress.Idle)
33+
34+
private val lock = Any()
35+
36+
/**
37+
* Active ops keyed by id, preserving insertion/update order. The last entry
38+
* in iteration order represents the most recently started or updated op and
39+
* drives the displayed [ViewModelProgress.Active.message] / amount.
40+
*/
41+
private val activeOps = linkedMapOf<Long, Op>()
42+
private val nextOpId = AtomicLong(0)
43+
44+
private data class Op(
45+
val message: String?,
46+
val amount: ProgressContext.Amount?,
47+
val onCancel: (() -> Unit)?,
48+
)
49+
50+
/**
51+
* Run [block] while indicating an operation is in progress.
52+
*
53+
* @param message optional message to display
54+
* @param onCancel if non-null, the dialog becomes cancellable and this
55+
* callback is invoked if the user dismisses it. Concurrent cancellable
56+
* ops all receive the cancel signal.
57+
* @param block the operation to run, with a [ProgressScope] receiver for
58+
* mid-operation updates
59+
*/
60+
suspend fun <T> withProgress(
61+
message: String? = null,
62+
onCancel: (() -> Unit)? = null,
63+
block: suspend ProgressScope.() -> T,
64+
): T {
65+
val opId = nextOpId.incrementAndGet()
66+
synchronized(lock) {
67+
activeOps[opId] = Op(message = message, amount = null, onCancel = onCancel)
68+
publishLocked()
69+
}
70+
try {
71+
return ProgressScope(this, opId).block()
72+
} finally {
73+
synchronized(lock) {
74+
activeOps.remove(opId)
75+
publishLocked()
76+
}
77+
}
78+
}
79+
80+
/**
81+
* Update the message/amount for [opId]. The updated op becomes the most
82+
* recently-updated entry so it drives the displayed state.
83+
*/
84+
internal fun updateOp(
85+
opId: Long,
86+
message: String?,
87+
amount: ProgressContext.Amount?,
88+
) {
89+
synchronized(lock) {
90+
val existing = activeOps.remove(opId) ?: return
91+
activeOps[opId] = existing.copy(message = message, amount = amount)
92+
publishLocked()
93+
}
94+
}
95+
96+
/**
97+
* Invoke every active op's cancel callback. Called by the UI layer when the
98+
* user dismisses a cancellable progress dialog.
99+
*/
100+
fun requestCancel() {
101+
val callbacks = synchronized(lock) { activeOps.values.mapNotNull { it.onCancel } }
102+
callbacks.forEach { it.invoke() }
103+
}
104+
105+
/** Must be called under [lock]. Publishes the derived state. */
106+
private fun publishLocked() {
107+
progress.value =
108+
if (activeOps.isEmpty()) {
109+
ViewModelProgress.Idle
110+
} else {
111+
val latest = activeOps.values.last()
112+
ViewModelProgress.Active(
113+
message = latest.message,
114+
amount = latest.amount,
115+
cancellable = activeOps.values.any { it.onCancel != null },
116+
)
117+
}
118+
}
119+
}
120+
121+
/**
122+
* Scope available inside [ProgressManager.withProgress] for updating progress
123+
* state mid-operation. Each scope is tied to a single active operation.
124+
*/
125+
class ProgressScope internal constructor(
126+
private val manager: ProgressManager,
127+
private val opId: Long,
128+
) {
129+
/**
130+
* Update the displayed progress for this operation. Cancellability is
131+
* derived from active ops and is not controlled from here.
132+
*/
133+
fun updateProgress(
134+
message: String? = null,
135+
amount: ProgressContext.Amount? = null,
136+
) {
137+
manager.updateOp(opId, message = message, amount = amount)
138+
}
139+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.progress
17+
18+
import androidx.fragment.app.Fragment
19+
import com.ichi2.anki.AnkiActivity
20+
import com.ichi2.anki.dialogs.LoadingDialogFragment
21+
import com.ichi2.anki.dialogs.dismissLoadingDialog
22+
import com.ichi2.anki.dialogs.showLoadingDialog
23+
import com.ichi2.anki.utils.ext.launchLatestCollectionInLifecycleScope
24+
import kotlinx.coroutines.delay
25+
import kotlin.time.Duration
26+
import kotlin.time.Duration.Companion.milliseconds
27+
28+
/**
29+
* Observes the [ProgressManager.progress] state from a [HasProgress] ViewModel
30+
* and automatically shows/dismisses a loading dialog.
31+
*
32+
* @param viewModel the ViewModel implementing [HasProgress]
33+
* @param delayMillis delay before showing the dialog, to avoid flashing for
34+
* quick operations
35+
*/
36+
fun AnkiActivity.observeProgress(
37+
viewModel: HasProgress,
38+
delayMillis: Duration = 600.milliseconds,
39+
) {
40+
// On configuration change, the FragmentManager restores the dialog before
41+
// we start collecting. Initialize from that state so we don't re-apply the
42+
// anti-flash delay against a dialog that's already visible.
43+
var dialogVisible =
44+
supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG) != null
45+
viewModel.progressManager.progress.launchLatestCollectionInLifecycleScope { state ->
46+
when (state) {
47+
is ViewModelProgress.Idle -> {
48+
dialogVisible = false
49+
dismissLoadingDialog()
50+
}
51+
is ViewModelProgress.Active -> {
52+
if (!dialogVisible) {
53+
delay(delayMillis)
54+
}
55+
showLoadingDialog(
56+
message = formatProgressMessage(state),
57+
cancellable = state.cancellable,
58+
)
59+
dialogVisible = true
60+
if (state.cancellable) {
61+
supportFragmentManager.executePendingTransactions()
62+
val fragment =
63+
supportFragmentManager.findFragmentByTag(
64+
LoadingDialogFragment.TAG,
65+
) as? LoadingDialogFragment
66+
fragment?.dialog?.setOnCancelListener {
67+
viewModel.progressManager.requestCancel()
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
75+
/**
76+
* Fragment version of [AnkiActivity.observeProgress].
77+
* Delegates to the hosting [AnkiActivity].
78+
*/
79+
fun Fragment.observeProgress(
80+
viewModel: HasProgress,
81+
delayMillis: Duration = 600.milliseconds,
82+
) {
83+
(requireActivity() as AnkiActivity).observeProgress(viewModel, delayMillis)
84+
}
85+
86+
private fun formatProgressMessage(state: ViewModelProgress.Active): String? {
87+
val amount = state.amount
88+
if (amount != null && state.message != null) {
89+
return "${state.message} ${amount.current}/${amount.max}"
90+
}
91+
return state.message
92+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.progress
17+
18+
import com.ichi2.anki.ProgressContext
19+
20+
/**
21+
* Represents the progress state of a ViewModel operation.
22+
* Observed by the UI layer to show/dismiss progress dialogs.
23+
*/
24+
sealed interface ViewModelProgress {
25+
/** No operation in progress */
26+
data object Idle : ViewModelProgress
27+
28+
/**
29+
* One or more operations in progress.
30+
* @param message human-readable description of the current operation
31+
* @param amount if non-null, represents progress as (current, max)
32+
* @param cancellable whether the user can cancel the operation
33+
*/
34+
data class Active(
35+
val message: String? = null,
36+
val amount: ProgressContext.Amount? = null,
37+
val cancellable: Boolean = false,
38+
) : ViewModelProgress
39+
}

AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,32 @@ fun <T> StateFlow<T>.launchCollectionInLifecycleScope(block: suspend (T) -> Unit
9898
}
9999
}
100100
}
101+
102+
/**
103+
* Variant of [launchCollectionInLifecycleScope] that uses [collectLatest]:
104+
* when a new value arrives while [block] is still running, the previous
105+
* invocation is cancelled before [block] runs for the new value.
106+
*
107+
* Useful when [block] contains suspending work (e.g. a `delay`) that should
108+
* be abandoned as soon as the source emits a newer value.
109+
*
110+
* Skips duplicate values (same as the previous one processed) so that
111+
* `StateFlow` re-emissions on re-resume don't re-run [block] unnecessarily.
112+
*/
113+
context(activity: AnkiActivity)
114+
fun <T> Flow<T>.launchLatestCollectionInLifecycleScope(block: suspend (T) -> Unit) {
115+
activity.lifecycleScope.launch {
116+
var lastValue: T? = null
117+
activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
118+
this@launchLatestCollectionInLifecycleScope.collectLatest {
119+
if (lastValue == it) return@collectLatest
120+
lastValue = it
121+
if (isRobolectric) {
122+
HandlerUtils.postOnNewHandler { runBlocking { block(it) } }
123+
} else {
124+
block(it)
125+
}
126+
}
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)