Skip to content

Commit 97cb3d7

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 97cb3d7

File tree

6 files changed

+511
-0
lines changed

6 files changed

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