diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt index 2ac167c7c06f..ac927864d548 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt @@ -23,12 +23,14 @@ import android.content.Context import android.content.Intent import android.os.SystemClock import android.widget.RemoteViews +import anki.collection.OpChanges import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CrashReportService import com.ichi2.anki.R import com.ichi2.anki.Reviewer import com.ichi2.anki.analytics.UsageAnalytics +import com.ichi2.libanki.ChangeManager import kotlinx.coroutines.launch import timber.log.Timber import kotlin.time.Duration.Companion.seconds @@ -60,7 +62,7 @@ data class DeckPickerWidgetData( * No user actions can be performed from this widget as of now; it is for display purposes only. * There is only one way to configure the widget i.e. while adding it on home screen, */ -class DeckPickerWidget : AnalyticsWidgetProvider() { +class DeckPickerWidget : AnalyticsWidgetProvider(), ChangeManager.Subscriber { companion object { /** @@ -98,7 +100,7 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { val remoteViews = RemoteViews(context.packageName, R.layout.widget_deck_picker_large) AnkiDroidApp.applicationScope.launch { - val deckData = getDeckNameAndStats(deckIds.toList().map { it }) + val deckData = getDeckNameAndStats(deckIds.toList()) remoteViews.removeAllViews(R.id.deckCollection) @@ -213,6 +215,9 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { } setRecurringAlarm(context, widgetId) } + + // Subscribe to changes + ChangeManager.subscribe(this) } override fun onReceive(context: Context?, intent: Intent?) { @@ -258,13 +263,32 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { } /** - * Cancels the recurring alarm for the deleted widgets, and the preference values associated to those widgets. + * Updates the `DeckPickerWidget` when changes in the Anki collection affect deck data. + * + * This method is triggered by the `ChangeManager` when operations impact study queues, such as adding or + * removing cards from decks. It updates all relevant widgets with the latest deck information. * - * @param context the context of the application - * @param appWidgetIds the array of widget IDs being deleted + * @param changes The `OpChanges` object containing details of the operations performed. + * @param handler The object that executed the operation. Updates are ignored if the handler is the current instance. */ + override fun opExecuted(changes: OpChanges, handler: Any?) { + if (changes.studyQueues && handler !== this) { + val context = AnkiDroidApp.instance.applicationContext + val appWidgetManager = AppWidgetManager.getInstance(context) + val widgetPreferences = WidgetPreferences(context) + + val widgetIds = widgetPreferences.getAllWidgetIds() + + for (widgetId in widgetIds) { + val selectedDeckIds = widgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(widgetId) + if (selectedDeckIds.isNotEmpty()) { + updateWidget(context, appWidgetManager, widgetId, selectedDeckIds) + } + } + } + } + override fun onDeleted(context: Context?, appWidgetIds: IntArray?) { - // Ensure context is not null if (context == null) { Timber.e("Context is null in onDeleted") return @@ -272,11 +296,13 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { val widgetPreferences = WidgetPreferences(context) - // Proceed with deleting widget preferences and canceling alarms appWidgetIds?.forEach { widgetId -> cancelRecurringAlarm(context, widgetId) widgetPreferences.deleteDeckPickerWidgetData(widgetId) } + + // Unsubscribe from changes + ChangeManager.clearSubscribers() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidgetConfig.kt index e11d94d9aaa7..274557d444ce 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidgetConfig.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidgetConfig.kt @@ -32,12 +32,14 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.floatingactionbutton.FloatingActionButton import com.ichi2.anki.AnkiActivity +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck import com.ichi2.anki.dialogs.DiscardChangesDialog import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.libanki.sched.DeckNode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -56,6 +58,7 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener { private lateinit var deckPickerWidgetPreferences: WidgetPreferences private val MAX_DECKS_ALLOWED = 5 // Maximum number of decks allowed in the widget private var hasUnsavedChanges = false // Flag to track unsaved changes + private var dueTree: DeckNode? = null override fun onCreate(savedInstanceState: Bundle?) { if (showedActivityFailedScreen(savedInstanceState)) { @@ -114,13 +117,28 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener { finish() } + // Load the due tree + loadDueTree() + findViewById(R.id.fabWidgetDeckPicker).setOnClickListener { - showDeckSelectionDialog() + lifecycleScope.launch { + val tree = dueTree + if (tree != null) { + // Check if the default deck is the only available deck and there are no cards + val isEmpty = isCollectionEmpty(tree, collectionIsEmpty = true) + if (isEmpty) { + showSnackbar(R.string.no_decks_available_message) + } else { + showDeckSelectionDialog() + } + } + } } // Load and display saved preferences loadSavedPreferences() + // Update the visibility of the "no decks" placeholder and the widget configuration container updateViewVisibility() // Register broadcast receiver to handle widget deletion @@ -288,6 +306,36 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener { context?.let { deleteWidgetDataDeckPickerWidget(appWidgetId) } } } + + /** + * Checks whether the collection is empty based on the structure of the provided deck tree and + * an external flag indicating whether the collection is empty. + * + * This function is specifically implemented to address an issue where the default deck + * (with `did` equal to `1L`) isn't handled correctly when a second deck is added to the + * collection. In this case, the deck tree may incorrectly appear as non-empty when it contains + * only the default deck and no other cards. + * + * @param tree The root node of the deck tree, representing the hierarchy of decks. + * @param collectionIsEmpty A flag indicating whether the collection is externally considered empty. + * @return `true` if the collection is empty based on the deck tree and the external flag; + * `false` otherwise. + */ + private fun isCollectionEmpty(tree: DeckNode, collectionIsEmpty: Boolean): Boolean { + val isEmpty = tree.children.size == 1 && tree.children[0].did == 1L && collectionIsEmpty + Timber.d("isEmpty: $isEmpty") + + return isEmpty + } + + /** + * Loads the due tree asynchronously. + */ + private fun loadDueTree() { + lifecycleScope.launch { + dueTree = withCol { sched.deckDueTree() } + } + } } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPreferences.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPreferences.kt index 7af568c1827a..2a12962ac893 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPreferences.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPreferences.kt @@ -51,4 +51,13 @@ class WidgetPreferences(context: Context) { putString("deck_picker_widget_selected_decks_$appWidgetId", selectedDecks.joinToString(",")) } } + + fun getAllWidgetIds(): IntArray { + val widgetIdsString = deckPickerSharedPreferences.getString("all_widget_ids", "") + return if (!widgetIdsString.isNullOrEmpty()) { + widgetIdsString.split(",").map { it.toInt() }.toIntArray() + } else { + intArrayOf() + } + } } diff --git a/AnkiDroid/src/main/res/values/08-widget.xml b/AnkiDroid/src/main/res/values/08-widget.xml index 50ab400c9274..0b542979b670 100644 --- a/AnkiDroid/src/main/res/values/08-widget.xml +++ b/AnkiDroid/src/main/res/values/08-widget.xml @@ -45,6 +45,7 @@ Select decks to display in the widgets Select decks with the + icon. Deck Removed + No decks available to select" Keep Editing Discard current input?