Skip to content

Commit 5900d22

Browse files
Feat (Progress Dialog): Compat to add new progress bar
1 parent cdba22b commit 5900d22

File tree

3 files changed

+221
-24
lines changed

3 files changed

+221
-24
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.ichi2.anki.CrashReportData.HelpAction
4040
import com.ichi2.anki.CrashReportData.HelpAction.AnkiBackendLink
4141
import com.ichi2.anki.CrashReportData.HelpAction.OpenDeckOptions
4242
import com.ichi2.anki.common.annotations.UseContextParameter
43+
import com.ichi2.anki.dialogs.ProgressCompat
4344
import com.ichi2.anki.exception.StorageAccessException
4445
import com.ichi2.anki.libanki.Collection
4546
import com.ichi2.anki.pages.DeckOptionsDestination
@@ -417,29 +418,28 @@ suspend fun <T> Fragment.withProgress(
417418
block: suspend () -> T,
418419
): T = requireActivity().withProgress(messageId, block)
419420

420-
@Suppress("Deprecation") // ProgressDialog deprecation
421-
suspend fun <T> withProgressDialog(
421+
suspend fun <T> withBlockingProgress(
422422
context: Activity,
423423
onCancel: (() -> Unit)?,
424424
delayMillis: Long = 600,
425425
@StringRes manualCancelButton: Int? = null,
426-
op: suspend (android.app.ProgressDialog) -> T,
426+
op: suspend (ProgressCompat) -> T,
427427
): T =
428428
coroutineScope {
429429
val dialog =
430-
android.app.ProgressDialog(context, R.style.AppCompatProgressDialogStyle).apply {
431-
setCancelable(onCancel != null)
430+
ProgressCompat(context).apply {
432431
if (manualCancelButton != null) {
433432
setCancelable(false)
434433
setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(manualCancelButton)) { _, _ ->
435434
Timber.i("Progress dialog cancelled via cancel button")
436-
onCancel?.let { it() }
435+
onCancel?.invoke()
437436
}
438437
} else {
439-
onCancel?.let {
438+
setCancelable(onCancel != null)
439+
onCancel?.let { action ->
440440
setOnCancelListener {
441441
Timber.i("Progress dialog cancelled via cancel listener")
442-
it()
442+
action()
443443
}
444444
}
445445
}
@@ -454,38 +454,34 @@ suspend fun <T> withProgressDialog(
454454
launch {
455455
delay(delayMillis)
456456
if (!AnkiDroidApp.instance.progressDialogShown) {
457-
Timber.i(
458-
"""Displaying progress dialog: ${delayMillis}ms elapsed;
459-
|cancellable: ${onCancel != null};
460-
|manualCancel: ${manualCancelButton != null}
461-
|
462-
""".trimMargin(),
463-
)
457+
Timber.i("Displaying progress dialog: ${delayMillis}ms elapsed")
464458
dialog.show()
465459
AnkiDroidApp.instance.progressDialogShown = true
466460
dialogIsOurs = true
467461
} else {
468-
Timber.w(
469-
"""A progress dialog is already displayed, not displaying progress dialog:
470-
|cancellable: ${onCancel != null};
471-
|manualCancel: ${manualCancelButton != null}
472-
|
473-
""".trimMargin(),
474-
)
462+
Timber.w("A progress dialog is already displayed; skipping new dialog.")
475463
}
476464
}
477465
try {
478466
op(dialog)
479467
} finally {
480468
dialogJob.cancel()
481-
dismissDialogIfShowing(dialog)
469+
dialog.dismiss()
482470
context.runOnUiThread { context.window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) }
483471
if (dialogIsOurs) {
484472
AnkiDroidApp.instance.progressDialogShown = false
485473
}
486474
}
487475
}
488476

477+
suspend fun <T> withProgressDialog(
478+
context: Activity,
479+
onCancel: (() -> Unit)?,
480+
delayMillis: Long = 600,
481+
@StringRes manualCancelButton: Int? = null,
482+
op: suspend (ProgressCompat) -> T,
483+
): T = withBlockingProgress(context, onCancel, delayMillis, manualCancelButton, op)
484+
489485
private fun dismissDialogIfShowing(dialog: Dialog) {
490486
try {
491487
if (dialog.isShowing) {
@@ -533,7 +529,7 @@ data class ProgressContext(
533529
)
534530

535531
@Suppress("Deprecation") // ProgressDialog deprecation
536-
private fun ProgressContext.updateDialog(dialog: android.app.ProgressDialog) {
532+
private fun ProgressContext.updateDialog(dialog: ProgressCompat) {
537533
// ideally this would show a progress bar, but MaterialDialog does not support
538534
// setting progress after starting with indeterminate progress, so we just use
539535
// this for now
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright (c) 2025 Shaan Narendran <[email protected]>
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+
17+
package com.ichi2.anki.dialogs
18+
19+
import android.content.Context
20+
import android.content.DialogInterface
21+
import android.os.Handler
22+
import android.os.Looper
23+
import android.view.LayoutInflater
24+
import android.widget.TextView
25+
import androidx.appcompat.app.AlertDialog
26+
import com.google.android.material.dialog.MaterialAlertDialogBuilder
27+
import com.google.android.material.progressindicator.CircularProgressIndicator
28+
import com.ichi2.anki.R
29+
30+
/**
31+
* Replacement for [android.app.ProgressDialog] deprecation on API 26+. This class should be used
32+
* instead of the platform [android.app.ProgressDialog].
33+
*
34+
* @see android.app.ProgressDialog
35+
*/
36+
class ProgressCompat(
37+
private val context: Context,
38+
) {
39+
companion object {
40+
private val PROGRESS_PATTERN = Regex("(\\d+)\\s*/\\s*(\\d+)")
41+
}
42+
43+
private var dialog: AlertDialog? = null
44+
private var circularIndicator: CircularProgressIndicator? = null
45+
private var messageView: TextView? = null
46+
private var pendingMessage: CharSequence? = null
47+
private var negativeButtonAction: Pair<String, (DialogInterface, Int) -> Unit>? = null
48+
private var onCancelListener: DialogInterface.OnCancelListener? = null
49+
private var isCancelable = false
50+
private var max = 100
51+
private var progress = 0
52+
53+
private val handler by lazy { Handler(Looper.getMainLooper()) }
54+
55+
private inline fun runOnUi(crossinline action: () -> Unit) {
56+
if (Looper.myLooper() == Looper.getMainLooper()) action() else handler.post { action() }
57+
}
58+
59+
fun setCancelable(flag: Boolean) {
60+
isCancelable = flag
61+
}
62+
63+
fun setOnCancelListener(listener: DialogInterface.OnCancelListener?) {
64+
onCancelListener = listener
65+
}
66+
67+
fun setButton(
68+
whichButton: Int,
69+
text: CharSequence,
70+
listener: (DialogInterface, Int) -> Unit,
71+
) {
72+
if (whichButton == DialogInterface.BUTTON_NEGATIVE) {
73+
negativeButtonAction = text.toString() to listener
74+
}
75+
}
76+
77+
/**
78+
* Updates the message text.
79+
*
80+
* This method attempts to parse progress numbers from the [message] string.
81+
* If the message contains a pattern like "50 / 100", the progress bar will
82+
* automatically update to match those values.
83+
*
84+
* @param message The text to display, or null to clear.
85+
*/
86+
fun setMessage(message: CharSequence?) =
87+
runOnUi {
88+
pendingMessage = message
89+
messageView?.text = message
90+
91+
message?.let { msg ->
92+
PROGRESS_PATTERN.find(msg)?.destructured?.let { (curr, total) ->
93+
runCatching {
94+
val t = total.toInt()
95+
if (max != t) setMaxInternal(t)
96+
setProgressInternal(curr.toInt())
97+
}
98+
return@runOnUi
99+
}
100+
// If no digits found, ensure we are in indeterminate mode (spinner)
101+
if (circularIndicator?.isIndeterminate == false && progress == 0) {
102+
circularIndicator?.isIndeterminate = true
103+
}
104+
}
105+
}
106+
107+
fun setMax(max: Int) = runOnUi { setMaxInternal(max) }
108+
109+
private fun setMaxInternal(max: Int) {
110+
this.max = max
111+
circularIndicator?.max = max
112+
}
113+
114+
fun setProgress(value: Int) = runOnUi { setProgressInternal(value) }
115+
116+
private fun setProgressInternal(value: Int) {
117+
progress = value
118+
circularIndicator?.apply {
119+
if (isIndeterminate) isIndeterminate = false
120+
setProgressCompat(value, true)
121+
}
122+
}
123+
124+
fun incrementProgressBy(diff: Int) = runOnUi { setProgressInternal(progress + diff) }
125+
126+
var isIndeterminate: Boolean
127+
get() = circularIndicator?.isIndeterminate ?: false
128+
set(value) = runOnUi { circularIndicator?.isIndeterminate = value }
129+
130+
fun show() =
131+
runOnUi {
132+
if (dialog?.isShowing == true) return@runOnUi
133+
134+
val view = LayoutInflater.from(context).inflate(R.layout.dialog_circular_progress, null)
135+
circularIndicator =
136+
view.findViewById<CircularProgressIndicator>(R.id.circular_progress).apply {
137+
isIndeterminate = false
138+
max = this@ProgressCompat.max
139+
setProgressCompat(progress, false)
140+
}
141+
messageView = view.findViewById<TextView>(R.id.progress_message).apply { text = pendingMessage }
142+
143+
dialog =
144+
MaterialAlertDialogBuilder(context)
145+
.setView(view)
146+
.setCancelable(isCancelable)
147+
.setOnCancelListener(onCancelListener)
148+
.apply {
149+
negativeButtonAction?.let { (text, listener) -> setNegativeButton(text, listener) }
150+
}.create()
151+
152+
dialog?.show()
153+
}
154+
155+
fun dismiss() =
156+
runOnUi {
157+
runCatching { dialog?.dismiss() }
158+
}
159+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (c) 2025 Shaan Narendran <[email protected]>
4+
~
5+
~ This program is free software; you can redistribute it and/or modify it under
6+
~ the terms of the GNU General Public License as published by the Free Software
7+
~ Foundation; either version 3 of the License, or (at your option) any later
8+
~ version.
9+
~
10+
~ This program is distributed in the hope that it will be useful, but WITHOUT ANY
11+
~ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
12+
~ PARTICULAR PURPOSE. See the GNU General Public License for more details.
13+
~
14+
~ You should have received a copy of the GNU General Public License along with
15+
~ this program. If not, see <http://www.gnu.org/licenses/>.
16+
-->
17+
18+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
19+
xmlns:app="http://schemas.android.com/apk/res-auto"
20+
android:layout_width="match_parent"
21+
android:layout_height="wrap_content"
22+
android:orientation="horizontal"
23+
android:padding="24dp"
24+
android:gravity="center_vertical">
25+
26+
<com.google.android.material.progressindicator.CircularProgressIndicator
27+
android:id="@+id/circular_progress"
28+
android:layout_width="wrap_content"
29+
android:layout_height="wrap_content"
30+
android:layout_marginEnd="24dp"
31+
app:indicatorSize="40dp"
32+
app:trackThickness="4dp"
33+
android:max="100" />
34+
35+
<TextView
36+
android:id="@+id/progress_message"
37+
android:layout_width="wrap_content"
38+
android:layout_height="wrap_content"
39+
android:text="@string/dialog_processing"
40+
android:textAppearance="?attr/textAppearanceBody1" />
41+
42+
</LinearLayout>

0 commit comments

Comments
 (0)