diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 82eb147c84c1..71036b443adf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -40,6 +40,7 @@ import com.ichi2.anki.CrashReportData.HelpAction import com.ichi2.anki.CrashReportData.HelpAction.AnkiBackendLink import com.ichi2.anki.CrashReportData.HelpAction.OpenDeckOptions import com.ichi2.anki.common.annotations.UseContextParameter +import com.ichi2.anki.dialogs.ProgressCompat import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.pages.DeckOptionsDestination @@ -417,29 +418,28 @@ suspend fun Fragment.withProgress( block: suspend () -> T, ): T = requireActivity().withProgress(messageId, block) -@Suppress("Deprecation") // ProgressDialog deprecation -suspend fun withProgressDialog( +suspend fun withBlockingProgress( context: Activity, onCancel: (() -> Unit)?, delayMillis: Long = 600, @StringRes manualCancelButton: Int? = null, - op: suspend (android.app.ProgressDialog) -> T, + op: suspend (ProgressCompat) -> T, ): T = coroutineScope { val dialog = - android.app.ProgressDialog(context, R.style.AppCompatProgressDialogStyle).apply { - setCancelable(onCancel != null) + ProgressCompat(context).apply { if (manualCancelButton != null) { setCancelable(false) setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(manualCancelButton)) { _, _ -> Timber.i("Progress dialog cancelled via cancel button") - onCancel?.let { it() } + onCancel?.invoke() } } else { - onCancel?.let { + setCancelable(onCancel != null) + onCancel?.let { action -> setOnCancelListener { Timber.i("Progress dialog cancelled via cancel listener") - it() + action() } } } @@ -454,31 +454,19 @@ suspend fun withProgressDialog( launch { delay(delayMillis) if (!AnkiDroidApp.instance.progressDialogShown) { - Timber.i( - """Displaying progress dialog: ${delayMillis}ms elapsed; - |cancellable: ${onCancel != null}; - |manualCancel: ${manualCancelButton != null} - | - """.trimMargin(), - ) + Timber.i("Displaying progress dialog: ${delayMillis}ms elapsed") dialog.show() AnkiDroidApp.instance.progressDialogShown = true dialogIsOurs = true } else { - Timber.w( - """A progress dialog is already displayed, not displaying progress dialog: - |cancellable: ${onCancel != null}; - |manualCancel: ${manualCancelButton != null} - | - """.trimMargin(), - ) + Timber.w("A progress dialog is already displayed; skipping new dialog.") } } try { op(dialog) } finally { dialogJob.cancel() - dismissDialogIfShowing(dialog) + dialog.dismiss() context.runOnUiThread { context.window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) } if (dialogIsOurs) { AnkiDroidApp.instance.progressDialogShown = false @@ -486,6 +474,14 @@ suspend fun withProgressDialog( } } +suspend fun withProgressDialog( + context: Activity, + onCancel: (() -> Unit)?, + delayMillis: Long = 600, + @StringRes manualCancelButton: Int? = null, + op: suspend (ProgressCompat) -> T, +): T = withBlockingProgress(context, onCancel, delayMillis, manualCancelButton, op) + private fun dismissDialogIfShowing(dialog: Dialog) { try { if (dialog.isShowing) { @@ -533,7 +529,7 @@ data class ProgressContext( ) @Suppress("Deprecation") // ProgressDialog deprecation -private fun ProgressContext.updateDialog(dialog: android.app.ProgressDialog) { +private fun ProgressContext.updateDialog(dialog: ProgressCompat) { // ideally this would show a progress bar, but MaterialDialog does not support // setting progress after starting with indeterminate progress, so we just use // this for now diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProgressCompat.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProgressCompat.kt new file mode 100644 index 000000000000..e265a7b439c1 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProgressCompat.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Shaan Narendran + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.dialogs + +import android.content.Context +import android.content.DialogInterface +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.progressindicator.CircularProgressIndicator +import com.ichi2.anki.R + +/** + * Replacement for [android.app.ProgressDialog] deprecation on API 26+. This class should be used + * instead of the platform [android.app.ProgressDialog]. + * + * @see android.app.ProgressDialog + */ +class ProgressCompat( + private val context: Context, +) { + companion object { + private val PROGRESS_PATTERN = Regex("(\\d+)\\s*/\\s*(\\d+)") + } + + private var dialog: AlertDialog? = null + private var circularIndicator: CircularProgressIndicator? = null + private var messageView: TextView? = null + private var pendingMessage: CharSequence? = null + private var negativeButtonAction: Pair Unit>? = null + private var onCancelListener: DialogInterface.OnCancelListener? = null + private var isCancelable = false + private var max = 100 + private var progress = 0 + + private val handler by lazy { Handler(Looper.getMainLooper()) } + + private inline fun runOnUi(crossinline action: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) action() else handler.post { action() } + } + + fun setCancelable(flag: Boolean) { + isCancelable = flag + } + + fun setOnCancelListener(listener: DialogInterface.OnCancelListener?) { + onCancelListener = listener + } + + fun setButton( + whichButton: Int, + text: CharSequence, + listener: (DialogInterface, Int) -> Unit, + ) { + if (whichButton == DialogInterface.BUTTON_NEGATIVE) { + negativeButtonAction = text.toString() to listener + } + } + + /** + * Updates the message text. + * + * This method attempts to parse progress numbers from the [message] string. + * If the message contains a pattern like "50 / 100", the progress bar will + * automatically update to match those values. + * + * @param message The text to display, or null to clear. + */ + fun setMessage(message: CharSequence?) = + runOnUi { + pendingMessage = message + messageView?.text = message + + message?.let { msg -> + PROGRESS_PATTERN.find(msg)?.destructured?.let { (curr, total) -> + runCatching { + val t = total.toInt() + if (max != t) setMaxInternal(t) + setProgressInternal(curr.toInt()) + } + return@runOnUi + } + // If no digits found, ensure we are in indeterminate mode (spinner) + if (circularIndicator?.isIndeterminate == false && progress == 0) { + circularIndicator?.isIndeterminate = true + } + } + } + + fun setMax(max: Int) = runOnUi { setMaxInternal(max) } + + private fun setMaxInternal(max: Int) { + this.max = max + circularIndicator?.max = max + } + + fun setProgress(value: Int) = runOnUi { setProgressInternal(value) } + + private fun setProgressInternal(value: Int) { + progress = value + circularIndicator?.apply { + if (isIndeterminate) isIndeterminate = false + setProgressCompat(value, true) + } + } + + fun incrementProgressBy(diff: Int) = runOnUi { setProgressInternal(progress + diff) } + + var isIndeterminate: Boolean + get() = circularIndicator?.isIndeterminate ?: false + set(value) = runOnUi { circularIndicator?.isIndeterminate = value } + + fun show() = + runOnUi { + if (dialog?.isShowing == true) return@runOnUi + + val view = LayoutInflater.from(context).inflate(R.layout.dialog_circular_progress, null) + circularIndicator = + view.findViewById(R.id.circular_progress).apply { + isIndeterminate = false + max = this@ProgressCompat.max + setProgressCompat(progress, false) + } + messageView = view.findViewById(R.id.progress_message).apply { text = pendingMessage } + + dialog = + MaterialAlertDialogBuilder(context) + .setView(view) + .setCancelable(isCancelable) + .setOnCancelListener(onCancelListener) + .apply { + negativeButtonAction?.let { (text, listener) -> setNegativeButton(text, listener) } + }.create() + + dialog?.show() + } + + fun dismiss() = + runOnUi { + runCatching { dialog?.dismiss() } + } +} diff --git a/AnkiDroid/src/main/res/layout/dialog_circular_progress.xml b/AnkiDroid/src/main/res/layout/dialog_circular_progress.xml new file mode 100644 index 000000000000..5c5780950dd7 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_circular_progress.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file