Skip to content

Commit b6fabdb

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 9b13c64 commit b6fabdb

6 files changed

Lines changed: 505 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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 kotlinx.coroutines.flow.asStateFlow
22+
import java.util.concurrent.atomic.AtomicInteger
23+
24+
/**
25+
* Manages progress state for ViewModel operations.
26+
*
27+
* Supports concurrent operations via reference counting: the progress dialog
28+
* stays visible as long as any operation is active. When all operations complete,
29+
* the state returns to [ViewModelProgress.Idle].
30+
*/
31+
class ProgressManager {
32+
private val _progress = MutableStateFlow<ViewModelProgress>(ViewModelProgress.Idle)
33+
34+
/** Observable progress state for the UI layer */
35+
val progress: StateFlow<ViewModelProgress> = _progress.asStateFlow()
36+
37+
private val activeCount = AtomicInteger(0)
38+
39+
/** The cancel callback for the currently active cancellable operation, if any */
40+
@Volatile
41+
private var currentOnCancel: (() -> Unit)? = null
42+
43+
/**
44+
* Run [block] while indicating an operation is in progress.
45+
* The progress UI is shown while at least one [withProgress] call is active.
46+
*
47+
* @param message optional message to display
48+
* @param onCancel if non-null, the dialog is cancellable and this callback is
49+
* invoked when the user cancels. Mirrors the old
50+
* [FragmentActivity.withProgress] `onCancel` parameter.
51+
* @param block the operation to run, with a [ProgressScope] receiver for mid-operation updates
52+
*/
53+
suspend fun <T> withProgress(
54+
message: String? = null,
55+
onCancel: (() -> Unit)? = null,
56+
block: suspend ProgressScope.() -> T,
57+
): T {
58+
activeCount.incrementAndGet()
59+
if (onCancel != null) {
60+
currentOnCancel = onCancel
61+
}
62+
updateState(message = message, amount = null, cancellable = onCancel != null)
63+
try {
64+
return ProgressScope(this).block()
65+
} finally {
66+
if (onCancel != null) {
67+
currentOnCancel = null
68+
}
69+
if (activeCount.decrementAndGet() == 0) {
70+
_progress.value = ViewModelProgress.Idle
71+
}
72+
}
73+
}
74+
75+
internal fun updateState(
76+
message: String?,
77+
amount: ProgressContext.Amount?,
78+
cancellable: Boolean,
79+
) {
80+
_progress.value =
81+
ViewModelProgress.Active(
82+
message = message,
83+
amount = amount,
84+
cancellable = cancellable,
85+
)
86+
}
87+
88+
/**
89+
* Called by the UI layer when the user dismisses a cancellable progress dialog.
90+
* Invokes the [onCancel] callback provided to [withProgress].
91+
*/
92+
fun requestCancel() {
93+
currentOnCancel?.invoke()
94+
}
95+
}
96+
97+
/**
98+
* Scope available inside [ProgressManager.withProgress] for updating
99+
* progress state mid-operation.
100+
*/
101+
class ProgressScope internal constructor(
102+
private val manager: ProgressManager,
103+
) {
104+
/**
105+
* Update the displayed progress.
106+
* @param message new message to display
107+
* @param amount optional progress amount (current, max)
108+
* @param cancellable whether the operation is cancellable
109+
*/
110+
fun updateProgress(
111+
message: String? = null,
112+
amount: ProgressContext.Amount? = null,
113+
cancellable: Boolean = false,
114+
) {
115+
manager.updateState(message = message, amount = amount, cancellable = cancellable)
116+
}
117+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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.dismissLoadingDialog
24+
import com.ichi2.anki.dialogs.showLoadingDialog
25+
import kotlinx.coroutines.Job
26+
import kotlinx.coroutines.delay
27+
import kotlinx.coroutines.launch
28+
29+
/**
30+
* Observes the [ProgressManager.progress] state from a [HasProgress] ViewModel and
31+
* automatically shows/dismisses a loading dialog.
32+
*
33+
* @param viewModel the ViewModel implementing [HasProgress]
34+
* @param delayMillis delay before showing the dialog, to avoid flashing for quick operations
35+
*/
36+
fun AnkiActivity.observeProgress(
37+
viewModel: HasProgress,
38+
delayMillis: Long = 600L,
39+
) {
40+
lifecycleScope.launch {
41+
repeatOnLifecycle(Lifecycle.State.STARTED) {
42+
var showJob: Job? = null
43+
viewModel.progressManager.progress.collect { state ->
44+
when (state) {
45+
is ViewModelProgress.Idle -> {
46+
showJob?.cancel()
47+
showJob = null
48+
dismissLoadingDialog()
49+
}
50+
is ViewModelProgress.Active -> {
51+
val message = formatProgressMessage(state)
52+
if (showJob == null) {
53+
showJob =
54+
launch {
55+
delay(delayMillis)
56+
showLoadingDialog(
57+
message = message,
58+
cancellable = state.cancellable,
59+
)
60+
// Wire cancel: when user dismisses the dialog,
61+
// invoke the onCancel callback via ProgressManager
62+
if (state.cancellable) {
63+
val fragment =
64+
supportFragmentManager
65+
.findFragmentByTag(
66+
com.ichi2.anki.dialogs.LoadingDialogFragment.TAG,
67+
) as? com.ichi2.anki.dialogs.LoadingDialogFragment
68+
fragment?.dialog?.setOnCancelListener {
69+
viewModel.progressManager.requestCancel()
70+
}
71+
}
72+
}
73+
} else {
74+
// Update existing dialog message if already showing
75+
showLoadingDialog(
76+
message = message,
77+
cancellable = state.cancellable,
78+
)
79+
}
80+
}
81+
}
82+
}
83+
}
84+
}
85+
}
86+
87+
/**
88+
* Fragment version of [AnkiActivity.observeProgress].
89+
* Delegates to the hosting [AnkiActivity].
90+
*/
91+
fun Fragment.observeProgress(
92+
viewModel: HasProgress,
93+
delayMillis: Long = 600L,
94+
) {
95+
(requireActivity() as AnkiActivity).observeProgress(viewModel, delayMillis)
96+
}
97+
98+
private fun formatProgressMessage(state: ViewModelProgress.Active): String? {
99+
val amount = state.amount
100+
if (amount != null && state.message != null) {
101+
return "${state.message} ${amount.current}/${amount.max}"
102+
}
103+
return state.message
104+
}
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+
}

0 commit comments

Comments
 (0)