diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml
index 6d4dd7c82b..6d917b81c3 100644
--- a/AnkiDroid/src/main/AndroidManifest.xml
+++ b/AnkiDroid/src/main/AndroidManifest.xml
@@ -520,6 +520,33 @@
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt
new file mode 100644
index 0000000000..a45c0b5be0
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt
@@ -0,0 +1,285 @@
+/*
+ * Copyright (c) 2024 Anoop
+ *
+ * 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.widget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+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.anki.pages.DeckOptions
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+typealias DeckId = Long
+typealias AppWidgetId = Int
+
+/**
+ * Data class representing the data for a deck displayed in the widget.
+ *
+ * @property deckId The ID of the deck.
+ * @property name The name of the deck.
+ * @property reviewCount The number of cards due for review.
+ * @property learnCount The number of cards in the learning phase.
+ * @property newCount The number of new cards.
+ */
+data class DeckPickerWidgetData(
+ val deckId: DeckId,
+ val name: String,
+ val reviewCount: Int,
+ val learnCount: Int,
+ val newCount: Int
+)
+
+/**
+ * This widget displays a list of decks with their respective new, learning, and review card counts.
+ * It updates every minute.
+ * It can be resized vertically & horizontally.
+ * It allows user to open the reviewer directly by clicking on the deck same as deckpicker.
+ * There is only one way to configure the widget i.e. while adding it on home screen,
+ */
+class DeckPickerWidget : AnalyticsWidgetProvider() {
+
+ companion object {
+ /**
+ * Action identifier to trigger updating the app widget.
+ * This constant is used to trigger the update of all widgets by the AppWidgetManager.
+ */
+ const val ACTION_APPWIDGET_UPDATE = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+
+ /**
+ * Custom action to update the widget.
+ * This constant is used to trigger the widget update via a custom broadcast intent.
+ */
+ const val ACTION_UPDATE_WIDGET = "com.ichi2.widget.ACTION_UPDATE_WIDGET"
+
+ /**
+ * Key used for passing the selected deck IDs in the intent extras.
+ */
+ const val EXTRA_SELECTED_DECK_IDS = "deck_picker_widget_selected_deck_ids"
+
+ /**
+ * Updates the widget with the deck data.
+ *
+ * This method replaces the entire view content with entries for each deck ID
+ * provided in the `deckIds` array. If any decks are deleted,
+ * they will be ignored, and only the rest of the decks will be displayed.
+ *
+ * @param context the context of the application
+ * @param appWidgetManager the AppWidgetManager instance
+ * @param appWidgetId the ID of the app widget
+ * @param deckIds the array of deck IDs to be displayed in the widget.
+ * Each ID corresponds to a specific deck, and the view will
+ * contain exactly the decks whose IDs are in this list.
+ *
+ * TODO: If the deck is completely empty (no cards at all), display a Snackbar or Toast message
+ * saying "The deck is empty" instead of opening any activity.
+ *
+ */
+ fun updateWidget(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: AppWidgetId,
+ deckIds: LongArray
+ ) {
+ val remoteViews = RemoteViews(context.packageName, R.layout.widget_deck_picker_large)
+
+ AnkiDroidApp.applicationScope.launch {
+ val deckData = getDeckNameAndStats(deckIds.toList())
+
+ remoteViews.removeAllViews(R.id.deckCollection)
+
+ for (deck in deckData) {
+ val deckView = RemoteViews(context.packageName, R.layout.widget_item_deck_main)
+
+ deckView.setTextViewText(R.id.deckName, deck.name)
+ deckView.setTextViewText(R.id.deckNew, deck.newCount.toString())
+ deckView.setTextViewText(R.id.deckDue, deck.reviewCount.toString())
+ deckView.setTextViewText(R.id.deckLearn, deck.learnCount.toString())
+
+ val isEmptyDeck = deck.newCount == 0 && deck.reviewCount == 0 && deck.learnCount == 0
+
+ if (!isEmptyDeck) {
+ val intent = Intent(context, Reviewer::class.java).apply {
+ action = Intent.ACTION_VIEW
+ putExtra("deckId", deck.deckId)
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ deck.deckId.toInt(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ deckView.setOnClickPendingIntent(R.id.deckName, pendingIntent)
+ } else {
+ val intent = DeckOptions.getIntent(context, deck.deckId)
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ deck.deckId.toInt(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ deckView.setOnClickPendingIntent(R.id.deckName, pendingIntent)
+ }
+
+ remoteViews.addView(R.id.deckCollection, deckView)
+ }
+ appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
+ }
+ }
+ }
+
+ override fun performUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ usageAnalytics: UsageAnalytics
+ ) {
+ Timber.d("Performing widget update for appWidgetIds: ${appWidgetIds.joinToString(", ")}")
+
+ val widgetPreferences = WidgetPreferences(context)
+
+ for (widgetId in appWidgetIds) {
+ Timber.d("Updating widget with ID: $widgetId")
+ val selectedDeckIds = widgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(widgetId)
+
+ /**Explanation of behavior when selectedDeckIds is empty
+ * If selectedDeckIds is empty, the widget will retain the previous deck list.
+ * This behavior ensures that the widget does not display an empty view, which could be
+ * confusing to the user. Instead, it maintains the last known state until a new valid
+ * list of deck IDs is provided. This approach prioritizes providing a consistent
+ * user experience over showing an empty or default state.
+ */
+ if (selectedDeckIds.isNotEmpty()) {
+ Timber.d("Selected deck IDs: ${selectedDeckIds.joinToString(", ")} for widget ID: $widgetId")
+ updateWidget(context, appWidgetManager, widgetId, selectedDeckIds)
+ }
+ }
+
+ Timber.d("Widget update process completed for appWidgetIds: ${appWidgetIds.joinToString(", ")}")
+ }
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (context == null || intent == null) {
+ Timber.e("Context or intent is null in onReceive")
+ return
+ }
+ super.onReceive(context, intent)
+
+ val widgetPreferences = WidgetPreferences(context)
+
+ when (intent.action) {
+ ACTION_APPWIDGET_UPDATE -> {
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+
+ // Retrieve the widget ID from the intent
+ val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ val selectedDeckIds = intent.getLongArrayExtra(EXTRA_SELECTED_DECK_IDS)
+
+ Timber.d("Received ACTION_APPWIDGET_UPDATE with widget ID: $appWidgetId and selectedDeckIds: ${selectedDeckIds?.joinToString(", ")}")
+
+ if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && selectedDeckIds != null) {
+ Timber.d("Updating widget with ID: $appWidgetId")
+ updateWidget(context, appWidgetManager, appWidgetId, selectedDeckIds)
+ Timber.d("Widget update process completed for widget ID: $appWidgetId")
+ }
+ }
+ // This custom action is received to update a specific widget.
+ // It is triggered by the setRecurringAlarm method to refresh the widget's data periodically.
+ ACTION_UPDATE_WIDGET -> {
+ val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
+ Timber.d("Received ACTION_UPDATE_WIDGET for widget ID: $appWidgetId")
+ }
+ }
+ AppWidgetManager.ACTION_APPWIDGET_DELETED -> {
+ Timber.d("ACTION_APPWIDGET_DELETED received")
+ val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
+ Timber.d("Deleting widget with ID: $appWidgetId")
+ widgetPreferences.deleteDeckPickerWidgetData(appWidgetId)
+ } else {
+ Timber.e("Invalid widget ID received in ACTION_APPWIDGET_DELETED")
+ }
+ }
+ AppWidgetManager.ACTION_APPWIDGET_ENABLED -> {
+ Timber.d("Widget enabled")
+ }
+ AppWidgetManager.ACTION_APPWIDGET_DISABLED -> {
+ Timber.d("Widget disabled")
+ }
+ else -> {
+ Timber.e("Unexpected action received: ${intent.action}")
+ CrashReportService.sendExceptionReport(
+ Exception("Unexpected action received: ${intent.action}"),
+ "DeckPickerWidget - onReceive",
+ null,
+ onlyIfSilent = true
+ )
+ }
+ }
+ }
+
+ override fun onDeleted(context: Context?, appWidgetIds: IntArray?) {
+ if (context == null) {
+ Timber.e("Context is null in onDeleted")
+ return
+ }
+
+ val widgetPreferences = WidgetPreferences(context)
+
+ appWidgetIds?.forEach { widgetId ->
+ widgetPreferences.deleteDeckPickerWidgetData(widgetId)
+ }
+ }
+}
+
+/**
+ * Map deck id to the associated DeckPickerWidgetData. Omits any id that does not correspond to a deck.
+ *
+ * Note: This operation may be slow, as it involves processing the entire deck collection.
+ *
+ * @param deckIds the list of deck IDs to retrieve data for
+ * @return a list of DeckPickerWidgetData objects containing deck names and statistics
+ */
+suspend fun getDeckNameAndStats(deckIds: List): List {
+ val result = mutableListOf()
+
+ val deckTree = withCol { sched.deckDueTree() }
+
+ deckTree.forEach { node ->
+ if (node.did !in deckIds) return@forEach
+ result.add(
+ DeckPickerWidgetData(
+ deckId = node.did,
+ name = node.lastDeckNameComponent,
+ reviewCount = node.revCount,
+ learnCount = node.lrnCount,
+ newCount = node.newCount
+ )
+ )
+ }
+
+ val deckIdToData = result.associateBy { it.deckId }
+ return deckIds.mapNotNull { deckIdToData[it] }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidgetConfig.kt
new file mode 100644
index 0000000000..d4a89adf48
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidgetConfig.kt
@@ -0,0 +1,489 @@
+/*
+ * Copyright (c) 2024 Anoop
+ *
+ * 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.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.view.View
+import android.widget.Button
+import androidx.activity.OnBackPressedCallback
+import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import com.google.android.material.snackbar.Snackbar
+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.showThemedToast
+import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
+import com.ichi2.anki.snackbar.SnackbarBuilder
+import com.ichi2.anki.snackbar.showSnackbar
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+/**
+ * Activity for configuring the Deck Picker Widget.
+ * This activity allows the user to select decks from deck selection dialog to be displayed in the widget.
+ * User can Select up to 5 decks.
+ * User Can remove, reorder decks and reconfigure by holding the widget.
+ */
+class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackbarBuilderProvider {
+
+ private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
+ lateinit var deckAdapter: WidgetConfigScreenAdapter
+ private lateinit var deckPickerWidgetPreferences: WidgetPreferences
+
+ /**
+ * Maximum number of decks allowed in the widget.
+ */
+ private val MAX_DECKS_ALLOWED = 5
+ private var hasUnsavedChanges = false
+ private var isAdapterObserverRegistered = false
+ private lateinit var onBackPressedCallback: OnBackPressedCallback
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ if (showedActivityFailedScreen(savedInstanceState)) {
+ return
+ }
+
+ super.onCreate(savedInstanceState)
+
+ if (!ensureStoragePermissions()) {
+ return
+ }
+
+ setContentView(R.layout.widget_deck_picker_config)
+
+ deckPickerWidgetPreferences = WidgetPreferences(this)
+
+ appWidgetId = intent.extras?.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ Timber.v("Invalid App Widget ID")
+ finish()
+ return
+ }
+
+ // Check if the collection is empty before proceeding and if the collection is empty, show a toast instead of the configuration view.
+ lifecycleScope.launch {
+ if (isCollectionEmpty()) {
+ showThemedToast(
+ this@DeckPickerWidgetConfig,
+ R.string.app_not_initialized_new,
+ false
+ )
+ finish()
+ return@launch
+ }
+
+ initializeUIComponents()
+ }
+ }
+
+ fun showSnackbar(message: CharSequence) {
+ showSnackbar(
+ message,
+ Snackbar.LENGTH_LONG
+ )
+ }
+
+ fun showSnackbar(messageResId: Int) {
+ showSnackbar(getString(messageResId))
+ }
+
+ fun initializeUIComponents() {
+ deckAdapter = WidgetConfigScreenAdapter { deck, position ->
+ deckAdapter.removeDeck(deck.deckId)
+ showSnackbar(R.string.deck_removed_from_widget)
+ updateViewVisibility()
+ updateFabVisibility()
+ updateDoneButtonVisibility()
+ hasUnsavedChanges = true
+ setUnsavedChanges(true)
+ }
+
+ findViewById(R.id.recyclerViewSelectedDecks).apply {
+ layoutManager = LinearLayoutManager(context)
+ adapter = this@DeckPickerWidgetConfig.deckAdapter
+ val itemTouchHelper = ItemTouchHelper(itemTouchHelperCallback)
+ itemTouchHelper.attachToRecyclerView(this)
+ }
+
+ setupDoneButton()
+
+ // TODO: Implement multi-select functionality so that user can select desired decks in once.
+ findViewById(R.id.fabWidgetDeckPicker).setOnClickListener {
+ showDeckSelectionDialog()
+ }
+
+ updateViewWithSavedPreferences()
+
+ // Update the visibility of the "no decks" placeholder and the widget configuration container
+ updateViewVisibility()
+
+ registerReceiver(widgetRemovedReceiver, IntentFilter(AppWidgetManager.ACTION_APPWIDGET_DELETED))
+
+ onBackPressedCallback = object : OnBackPressedCallback(false) {
+ override fun handleOnBackPressed() {
+ if (hasUnsavedChanges) {
+ DiscardChangesDialog.showDialog(
+ context = this@DeckPickerWidgetConfig,
+ positiveMethod = {
+ // Set flag to indicate that changes are discarded
+ hasUnsavedChanges = false
+ finish()
+ }
+ )
+ } else {
+ finish()
+ }
+ }
+ }
+
+ onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
+
+ // Register the AdapterDataObserver if not already registered
+ if (!isAdapterObserverRegistered) {
+ deckAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
+ override fun onChanged() {
+ updateDoneButtonVisibility() // Update visibility when data changes
+ }
+ })
+ isAdapterObserverRegistered = true
+ }
+ }
+
+ private fun updateCallbackState() {
+ onBackPressedCallback.isEnabled = hasUnsavedChanges
+ }
+
+ // Call this method when there are unsaved changes
+ private fun setUnsavedChanges(unsaved: Boolean) {
+ hasUnsavedChanges = unsaved
+ updateCallbackState()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ unregisterReceiverSilently(widgetRemovedReceiver)
+ }
+
+ override val baseSnackbarBuilder: SnackbarBuilder = {
+ anchorView = findViewById(R.id.fabWidgetDeckPicker)
+ }
+
+ /**
+ * Configures the "Done" button based on the number of selected decks.
+ *
+ * If no decks are selected: The button is hidden.
+ * If decks are selected: The button is visible with the text "Save".
+ * When clicked, the selected decks are saved, the widget is updated,
+ * and the activity is finished.
+ */
+ private fun setupDoneButton() {
+ val doneButton = findViewById