Skip to content

Commit 3e1d53b

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 3e1d53b

6 files changed

Lines changed: 851 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: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
suspend fun <T> withProgress(
63+
message: String? = null,
64+
onCancel: (() -> Unit)? = null,
65+
formatAmount: (ProgressContext.Amount) -> String =
66+
{ (current, max) -> "$current/$max" },
67+
separator: String = " ",
68+
block: suspend ProgressScope.() -> T,
69+
): T {
70+
val opId = nextOpId.incrementAndGet()
71+
synchronized(lock) {
72+
activeOps[opId] =
73+
Op(
74+
message = message,
75+
amount = null,
76+
onCancel = onCancel,
77+
formatAmount = formatAmount,
78+
separator = separator,
79+
)
80+
publishLocked()
81+
}
82+
try {
83+
return ProgressScope(this, opId).block()
84+
} finally {
85+
synchronized(lock) {
86+
activeOps.remove(opId)
87+
publishLocked()
88+
}
89+
}
90+
}
91+
92+
/** Updates [opId] in place and moves it to the end so it becomes the displayed op. */
93+
internal fun updateOp(
94+
opId: Long,
95+
message: String?,
96+
amount: ProgressContext.Amount?,
97+
) {
98+
synchronized(lock) {
99+
val op = activeOps.remove(opId) ?: return
100+
op.message = message
101+
op.amount = amount
102+
activeOps[opId] = op
103+
publishLocked()
104+
}
105+
}
106+
107+
/** Called by the UI when the user dismisses the dialog. Fires every active `onCancel`. */
108+
fun requestCancel() {
109+
val callbacks = synchronized(lock) { activeOps.values.mapNotNull { it.onCancel } }
110+
callbacks.forEach { it.invoke() }
111+
}
112+
113+
/** Must be called under [lock]. */
114+
private fun publishLocked() {
115+
progress.value =
116+
if (activeOps.isEmpty()) {
117+
ViewModelProgress.Idle
118+
} else {
119+
val latest = activeOps.values.last()
120+
ViewModelProgress.Active(
121+
message = latest.message,
122+
amount = latest.amount,
123+
cancellable = activeOps.values.any { it.onCancel != null },
124+
formatAmount = latest.formatAmount,
125+
separator = latest.separator,
126+
)
127+
}
128+
}
129+
}
130+
131+
/** Receiver inside [ProgressManager.withProgress] for mid-operation updates. */
132+
class ProgressScope internal constructor(
133+
private val manager: ProgressManager,
134+
private val opId: Long,
135+
) {
136+
fun updateProgress(
137+
message: String? = null,
138+
amount: ProgressContext.Amount? = null,
139+
) {
140+
manager.updateOp(opId, message = message, amount = amount)
141+
}
142+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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+
* TODO: relax [showLoadingDialog]/[dismissLoadingDialog] to `FragmentActivity`
101+
* and drop this cast.
102+
*/
103+
fun Fragment.observeProgress(
104+
viewModel: HasProgress,
105+
delayMillis: Duration = 600.milliseconds,
106+
) {
107+
val activity = requireActivity() as? AnkiActivity
108+
if (activity == null) {
109+
Timber.w(
110+
"observeProgress called from a Fragment hosted by %s, which is not an AnkiActivity; skipping.",
111+
requireActivity().javaClass.simpleName,
112+
)
113+
return
114+
}
115+
activity.observeProgress(viewModel, delayMillis)
116+
}
117+
118+
private fun ViewModelProgress.Active.formatMessage(): String? {
119+
val amount = amount ?: return message
120+
val formattedAmount = formatAmount(amount)
121+
return when {
122+
message.isNullOrEmpty() -> formattedAmount
123+
else -> "$message$separator$formattedAmount"
124+
}
125+
}
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)