Skip to content

Commit 6076d83

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 e39e474 commit 6076d83

6 files changed

Lines changed: 884 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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
* Progress state shared by a ViewModel and its UI.
25+
*
26+
* Concurrent [withProgress] calls are supported: the flow stays [Active][ViewModelProgress.Active]
27+
* until every call finishes. The displayed message/amount comes from whichever op was last to
28+
* start or update (one dialog, last write wins). The dialog is cancellable if any active op
29+
* passed an `onCancel`, and [requestCancel] fires all of those callbacks.
30+
*/
31+
class ProgressManager {
32+
val progress: StateFlow<ViewModelProgress>
33+
field = MutableStateFlow<ViewModelProgress>(ViewModelProgress.Idle)
34+
35+
private val lock = Any()
36+
37+
/** Keyed by op id, iteration order is start/update order the last entry wins. */
38+
private val activeOps = linkedMapOf<Long, Op>()
39+
private val nextOpId = AtomicLong(0)
40+
41+
/**
42+
* Mutable per-op state. Mirrors [ProgressContext]'s `var`-field style so updates
43+
* don't allocate. The instance is reused across updates and only the map entry
44+
* is re-inserted (to move it to the "latest" position).
45+
*/
46+
private class Op(
47+
var message: String?,
48+
var amount: ProgressContext.Amount?,
49+
val onCancel: (() -> Unit)?,
50+
val formatAmount: (ProgressContext.Amount) -> String,
51+
val separator: String,
52+
)
53+
54+
/**
55+
* Run [block] while a progress dialog is shown.
56+
*
57+
* @param message initial message, or null for no text.
58+
* @param onCancel if non-null, the dialog becomes cancellable and this runs when dismissed.
59+
* @param formatAmount / [separator] control how [ProgressContext.Amount] is rendered.
60+
* See the class KDoc for how these combine across concurrent ops.
61+
*
62+
* TODO: [formatAmount], [separator] and [onCancel]-derived cancellability are
63+
* fixed for the lifetime of an op [ProgressScope.updateProgress] only mutates
64+
* message/amount. If a caller needs to change those mid-flight, expose a
65+
* dedicated API instead of overloading [updateProgress].
66+
*/
67+
suspend fun <T> withProgress(
68+
message: String? = null,
69+
onCancel: (() -> Unit)? = null,
70+
formatAmount: (ProgressContext.Amount) -> String =
71+
{ (current, max) -> "$current/$max" },
72+
separator: String = " ",
73+
block: suspend ProgressScope.() -> T,
74+
): T {
75+
val opId = nextOpId.incrementAndGet()
76+
synchronized(lock) {
77+
activeOps[opId] =
78+
Op(
79+
message = message,
80+
amount = null,
81+
onCancel = onCancel,
82+
formatAmount = formatAmount,
83+
separator = separator,
84+
)
85+
publishLocked()
86+
}
87+
try {
88+
return ProgressScope(this, opId).block()
89+
} finally {
90+
synchronized(lock) {
91+
activeOps.remove(opId)
92+
publishLocked()
93+
}
94+
}
95+
}
96+
97+
/**
98+
* Updates [opId] in place. The op keeps its position in [activeOps] — re-promoting
99+
* it would cause the displayed message to flicker between concurrent ops. Updates
100+
* from non-displayed ops are held internally and shown only when that op becomes
101+
* the displayed one (i.e. all later-started ops have ended).
102+
*
103+
* Only re-publishes if [opId] is currently the displayed op; otherwise the state
104+
* the UI sees is unchanged.
105+
*/
106+
internal fun updateOp(
107+
opId: Long,
108+
message: String?,
109+
amount: ProgressContext.Amount?,
110+
) {
111+
synchronized(lock) {
112+
val op = activeOps[opId] ?: return
113+
op.message = message
114+
op.amount = amount
115+
// Only the displayed op (last-started) drives the published state.
116+
if (activeOps.entries.last().key == opId) {
117+
publishLocked()
118+
}
119+
}
120+
}
121+
122+
/** Called by the UI when the user dismisses the dialog. Fires every active `onCancel`. */
123+
fun requestCancel() {
124+
val callbacks = synchronized(lock) { activeOps.values.mapNotNull { it.onCancel } }
125+
callbacks.forEach { it.invoke() }
126+
}
127+
128+
/** Must be called under [lock]. */
129+
private fun publishLocked() {
130+
progress.value =
131+
if (activeOps.isEmpty()) {
132+
ViewModelProgress.Idle
133+
} else {
134+
val latest = activeOps.values.last()
135+
ViewModelProgress.Active(
136+
message = latest.message,
137+
amount = latest.amount,
138+
cancellable = activeOps.values.any { it.onCancel != null },
139+
formatAmount = latest.formatAmount,
140+
separator = latest.separator,
141+
)
142+
}
143+
}
144+
}
145+
146+
/** Receiver inside [ProgressManager.withProgress] for mid-operation updates. */
147+
class ProgressScope internal constructor(
148+
private val manager: ProgressManager,
149+
private val opId: Long,
150+
) {
151+
fun updateProgress(
152+
message: String? = null,
153+
amount: ProgressContext.Amount? = null,
154+
) {
155+
manager.updateOp(opId, message = message, amount = amount)
156+
}
157+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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 androidx.lifecycle.Lifecycle
20+
import androidx.lifecycle.lifecycleScope
21+
import androidx.lifecycle.repeatOnLifecycle
22+
import com.ichi2.anki.AnkiActivity
23+
import com.ichi2.anki.dialogs.LoadingDialogFragment
24+
import com.ichi2.anki.dialogs.dismissLoadingDialog
25+
import com.ichi2.anki.dialogs.showLoadingDialog
26+
import kotlinx.coroutines.Job
27+
import kotlinx.coroutines.delay
28+
import kotlinx.coroutines.launch
29+
import timber.log.Timber
30+
import kotlin.time.Duration
31+
import kotlin.time.Duration.Companion.milliseconds
32+
33+
/**
34+
* Shows/dismisses a loading dialog driven by [viewModel]'s progress flow.
35+
* [delayMillis] defers the initial show so quick operations don't flash a dialog.
36+
*/
37+
fun AnkiActivity.observeProgress(
38+
viewModel: HasProgress,
39+
delayMillis: Duration = 600.milliseconds,
40+
) {
41+
var dialogVisible =
42+
supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG) != null
43+
// Pending "show after delay" job survives subsequent Active emissions so the
44+
// anti-flash delay isn't restarted on every progress update.
45+
var pendingShow: Job? = null
46+
47+
lifecycleScope.launch {
48+
repeatOnLifecycle(Lifecycle.State.STARTED) {
49+
viewModel.progressManager.progress.collect { state ->
50+
when (state) {
51+
is ViewModelProgress.Idle -> {
52+
pendingShow?.cancel()
53+
pendingShow = null
54+
dialogVisible = false
55+
dismissLoadingDialog()
56+
}
57+
is ViewModelProgress.Active -> {
58+
if (dialogVisible) {
59+
showLoadingDialog(
60+
message = state.formatMessage(),
61+
cancellable = state.cancellable,
62+
)
63+
if (state.cancellable) wireCancelListener(viewModel)
64+
} else if (pendingShow == null) {
65+
pendingShow =
66+
launch {
67+
delay(delayMillis)
68+
val latest = viewModel.progressManager.progress.value
69+
if (latest is ViewModelProgress.Active) {
70+
dialogVisible = true
71+
showLoadingDialog(
72+
message = latest.formatMessage(),
73+
cancellable = latest.cancellable,
74+
)
75+
if (latest.cancellable) wireCancelListener(viewModel)
76+
}
77+
pendingShow = null
78+
}
79+
}
80+
}
81+
}
82+
}
83+
}
84+
}
85+
}
86+
87+
private fun AnkiActivity.wireCancelListener(viewModel: HasProgress) {
88+
supportFragmentManager.executePendingTransactions()
89+
val fragment =
90+
supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG)
91+
as? LoadingDialogFragment
92+
fragment?.dialog?.setOnCancelListener {
93+
viewModel.progressManager.requestCancel()
94+
}
95+
}
96+
97+
/**
98+
* Fragment wrapper that delegates to the host activity.
99+
*
100+
* If the host is not an [AnkiActivity], logs a warning and returns — no observer is set up.
101+
*
102+
* @throws IllegalStateException if the fragment is not currently attached to an activity
103+
* (propagated from [Fragment.requireActivity]).
104+
*
105+
* TODO: relax [showLoadingDialog]/[dismissLoadingDialog] to `FragmentActivity` and drop
106+
* the cast.
107+
*/
108+
fun Fragment.observeProgress(
109+
viewModel: HasProgress,
110+
delayMillis: Duration = 600.milliseconds,
111+
) {
112+
val activity = requireActivity() as? AnkiActivity
113+
if (activity == null) {
114+
Timber.w(
115+
"observeProgress called from a Fragment hosted by %s, which is not an AnkiActivity; skipping.",
116+
requireActivity().javaClass.simpleName,
117+
)
118+
return
119+
}
120+
activity.observeProgress(viewModel, delayMillis)
121+
}
122+
123+
private fun ViewModelProgress.Active.formatMessage(): String? {
124+
val amount = amount ?: return message
125+
val formattedAmount = formatAmount(amount)
126+
return when {
127+
message.isNullOrEmpty() -> formattedAmount
128+
else -> "$message$separator$formattedAmount"
129+
}
130+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
/** Progress state observed by the UI. See [ProgressManager] for concurrent-op semantics. */
21+
sealed interface ViewModelProgress {
22+
data object Idle : ViewModelProgress
23+
24+
data class Active(
25+
val message: String? = null,
26+
val amount: ProgressContext.Amount? = null,
27+
val cancellable: Boolean = false,
28+
val formatAmount: (ProgressContext.Amount) -> String =
29+
{ (current, max) -> "$current/$max" },
30+
val separator: String = " ",
31+
) : ViewModelProgress
32+
}

0 commit comments

Comments
 (0)