diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index d9c9381946be..780700d1da00 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -17,9 +17,7 @@ package com.ichi2.anki import android.app.Activity -import android.app.Dialog import android.content.Context -import android.content.DialogInterface import android.net.Uri import android.view.WindowManager import android.view.WindowManager.BadTokenException @@ -40,6 +38,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.CircularProgressDialog import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.pages.DeckOptionsDestination @@ -410,21 +409,20 @@ suspend fun Fragment.withProgress( block: suspend () -> T, ): T = requireActivity().withProgress(messageId, block) -@Suppress("Deprecation") // ProgressDialog deprecation suspend fun withProgressDialog( context: Activity, onCancel: (() -> Unit)?, delayMillis: Long = 600, @StringRes manualCancelButton: Int? = null, - op: suspend (android.app.ProgressDialog) -> T, + op: suspend (CircularProgressDialog) -> T, ): T = coroutineScope { val dialog = - android.app.ProgressDialog(context, R.style.AppCompatProgressDialogStyle).apply { + CircularProgressDialog(context).apply { setCancelable(onCancel != null) if (manualCancelButton != null) { setCancelable(false) - setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(manualCancelButton)) { _, _ -> + setButton(manualCancelButton) { _, _ -> Timber.i("Progress dialog cancelled via cancel button") onCancel?.let { it() } } @@ -477,9 +475,9 @@ suspend fun withProgressDialog( } } -private fun dismissDialogIfShowing(dialog: Dialog) { +private fun dismissDialogIfShowing(dialog: CircularProgressDialog) { try { - if (dialog.isShowing) { + if (dialog.isShowing()) { dialog.dismiss() } } catch (e: Exception) { @@ -523,18 +521,18 @@ data class ProgressContext( var amount: Pair? = null, ) -@Suppress("Deprecation") // ProgressDialog deprecation -private fun ProgressContext.updateDialog(dialog: android.app.ProgressDialog) { - // 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 - // this code has since been updated to ProgressDialog, and the above not rechecked - val progressText = - amount?.let { - " ${it.first}/${it.second}" - } ?: "" - @Suppress("Deprecation") // ProgressDialog deprecation - dialog.setMessage(text + progressText) +private fun ProgressContext.updateDialog(dialog: CircularProgressDialog) { + // Update message + dialog.setMessage(text) + + // Update progress if we have amount information + amount?.let { (current, total) -> + dialog.setIndeterminate(false) + dialog.setProgress(current, total) + } ?: run { + // No amount info, keep it indeterminate + dialog.setIndeterminate(true) + } } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CircularProgressDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CircularProgressDialog.kt new file mode 100644 index 000000000000..99e4e26f742d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CircularProgressDialog.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 AnkiDroid Contributors + * + * 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.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import com.google.android.material.progressindicator.CircularProgressIndicator +import com.ichi2.anki.R + +class CircularProgressDialog( + context: Context, +) { + private val dialog: AlertDialog + private val progressIndicator: CircularProgressIndicator + private val percentageText: TextView + private val messageText: TextView + + private var isDeterminate = false + private var currentProgress = 0 + private var maxProgress = 100 + + init { + val view = LayoutInflater.from(context).inflate(R.layout.dialog_circular_progress, null) + progressIndicator = view.findViewById(R.id.circular_progress_indicator) + percentageText = view.findViewById(R.id.progress_percentage_text) + messageText = view.findViewById(R.id.progress_message_text) + + dialog = + AlertDialog + .Builder(context) + .setView(view) + .setCancelable(false) + .create() + } + + fun setMessage(message: String) { + messageText.text = message + messageText.visibility = if (message.isNotEmpty()) View.VISIBLE else View.GONE + updateAccessibility() + } + + fun setMessage( + @StringRes messageId: Int, + ) { + setMessage(messageText.context.getString(messageId)) + } + + fun setCancelable(cancelable: Boolean) { + dialog.setCancelable(cancelable) + } + + fun setOnCancelListener(listener: DialogInterface.OnCancelListener?) { + dialog.setOnCancelListener(listener) + } + + fun setButton( + @StringRes textId: Int, + listener: DialogInterface.OnClickListener, + ) { + dialog.setButton(DialogInterface.BUTTON_NEGATIVE, messageText.context.getString(textId), listener) + } + + fun setIndeterminate(determinate: Boolean) { + isDeterminate = !determinate + progressIndicator.isIndeterminate = !isDeterminate + + if (isDeterminate) { + percentageText.visibility = View.VISIBLE + updateProgress() + } else { + percentageText.visibility = View.GONE + } + updateAccessibility() + } + + fun setProgress(progress: Int) { + currentProgress = progress.coerceIn(0, maxProgress) + if (isDeterminate) { + updateProgress() + } + } + + fun setMax(max: Int) { + maxProgress = max.coerceAtLeast(1) + if (isDeterminate) { + updateProgress() + } + } + + fun setProgress( + current: Int, + total: Int, + ) { + setMax(total) + setProgress(current) + } + + private fun updateProgress() { + val percentage = + if (maxProgress > 0) { + (currentProgress * 100) / maxProgress + } else { + 0 + } + + progressIndicator.setProgressCompat(percentage, true) + percentageText.text = messageText.context.getString(R.string.progress_percentage, percentage) + updateAccessibility() + } + + private fun updateAccessibility() { + val contentDescription = + if (isDeterminate) { + val percentage = + if (maxProgress > 0) { + (currentProgress * 100) / maxProgress + } else { + 0 + } + messageText.context.getString( + R.string.progress_accessibility_determinate, + messageText.text, + percentage, + ) + } else { + messageText.context.getString( + R.string.progress_accessibility_indeterminate, + messageText.text, + ) + } + progressIndicator.contentDescription = contentDescription + } + + fun show() { + if (!dialog.isShowing) { + dialog.show() + } + } + + fun dismiss() { + if (dialog.isShowing) { + dialog.dismiss() + } + } + + fun isShowing(): Boolean = dialog.isShowing + + companion object { + fun show( + context: Context, + message: String, + ): CircularProgressDialog = + CircularProgressDialog(context).apply { + setMessage(message) + setIndeterminate(true) + show() + } + + fun show( + context: Context, + @StringRes messageId: Int, + ): CircularProgressDialog = show(context, context.getString(messageId)) + } +} 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..fbf6cd9045e5 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_circular_progress.xml @@ -0,0 +1,52 @@ + + + + + + + + + + diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 70059b4b1cee..c7535e4498af 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -437,5 +437,10 @@ opening the system text to speech settings fails">Failed to open text to speech Scheduler upgrade required + + + %d%% + %1$s. Progress: %2$d percent + %1$s. Progress indicator