From c829c3dfd7e60546689f0d6b17d00024c76046c3 Mon Sep 17 00:00:00 2001 From: Anoop Date: Wed, 28 Aug 2024 04:07:39 +0530 Subject: [PATCH] Implementation of Card Analysis Widget to display deck statistics for a particular deck. This commit introduces the Card Analysis Extra Widget, which displays a deck with the number of cards that are new, in learning, due for review and deck name. Features: - Displays deck names and statistics (new, learning, and review counts). - Updates every minute using a recurring alarm. --- AnkiDroid/src/main/AndroidManifest.xml | 27 ++ .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 2 + .../src/main/java/com/ichi2/anki/DeckUtils.kt | 55 +++ .../widget/cardanalysis/CardAnalysisWidget.kt | 236 +++++++++++ .../cardanalysis/CardAnalysisWidgetConfig.kt | 370 ++++++++++++++++++ .../CardAnalysisWidgetPreferences.kt | 66 ++++ .../widget/deckpicker/DeckPickerWidget.kt | 25 +- .../deckpicker/DeckPickerWidgetConfig.kt | 20 +- .../DeckPickerWidgetPreferences.kt} | 14 +- .../widget_card_analysis_drawable.jpg | Bin 0 -> 10099 bytes .../main/res/layout/widget_card_analysis.xml | 65 +++ .../widget_card_analysis_drawable_v31.xml | 61 +++ AnkiDroid/src/main/res/values/08-widget.xml | 4 +- .../res/xml/widget_provider_card_analysis.xml | 28 ++ .../CardAnalysisWidgetConfigTest.kt | 155 ++++++++ .../deckpicker/DeckPickerWidgetConfigTest.kt | 16 +- .../java/com/ichi2/testutils/ActivityList.kt | 4 +- 17 files changed, 1097 insertions(+), 51 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetPreferences.kt rename AnkiDroid/src/main/java/com/ichi2/widget/{WidgetPreferences.kt => deckpicker/DeckPickerWidgetPreferences.kt} (84%) create mode 100644 AnkiDroid/src/main/res/drawable/widget_card_analysis_drawable.jpg create mode 100644 AnkiDroid/src/main/res/layout/widget_card_analysis.xml create mode 100644 AnkiDroid/src/main/res/layout/widget_card_analysis_drawable_v31.xml create mode 100644 AnkiDroid/src/main/res/xml/widget_provider_card_analysis.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/widget/cardanalysis/CardAnalysisWidgetConfigTest.kt diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index 2cb577be3d..c051cf5a9d 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -538,6 +538,33 @@ + + + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 88ba05778c..9d7715d938 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -60,6 +60,7 @@ import com.ichi2.utils.ExceptionUtil import com.ichi2.utils.KotlinCleanup import com.ichi2.utils.LanguageUtil import com.ichi2.utils.Permissions +import com.ichi2.widget.cardanalysis.CardAnalysisWidget import com.ichi2.widget.deckpicker.DeckPickerWidget import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -298,6 +299,7 @@ open class AnkiDroidApp : Application(), Configuration.Provider, ChangeManager.S Timber.d("ChangeSubscriber - opExecuted called with changes: $changes") if (changes.studyQueues) { DeckPickerWidget.updateDeckPickerWidgets(this) + CardAnalysisWidget.updateCardAnalysisWidgets(this) } else { Timber.d("No relevant changes to update the widget") } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt new file mode 100644 index 0000000000..7348169642 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt @@ -0,0 +1,55 @@ +/* + * 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.anki + +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.libanki.Decks +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object DeckUtils { + + /** + * Checks if a given deck, including its subdecks if specified, is empty. + * + * @param decks The [Decks] instance containing the decks to check. + * @param deckId The ID of the deck to check. + * @param includeSubdecks If true, includes subdecks in the check. Default is true. + * @return `true` if the deck (and subdecks if specified) is empty, otherwise `false`. + */ + private fun isDeckEmpty(decks: Decks, deckId: Long, includeSubdecks: Boolean = true): Boolean { + val deckIds = decks.deckAndChildIds(deckId) + val totalCardCount = decks.cardCount(*deckIds.toLongArray(), includeSubdecks = includeSubdecks) + return totalCardCount == 0 + } + + /** + * Checks if the default deck is empty. + * + * This method runs on an IO thread and accesses the collection to determine if the default deck (with ID 1) is empty. + * + * @return `true` if the default deck is empty, otherwise `false`. + */ + suspend fun isDefaultDeckEmpty(): Boolean { + val defaultDeckId = 1L + return withContext(Dispatchers.IO) { + withCol { + isDeckEmpty(decks, defaultDeckId) + } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt new file mode 100644 index 0000000000..bf3f665fcb --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt @@ -0,0 +1,236 @@ +/* + * 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.cardanalysis + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import com.ichi2.anki.AnkiDroidApp +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 com.ichi2.widget.ACTION_UPDATE_WIDGET +import com.ichi2.widget.AnalyticsWidgetProvider +import com.ichi2.widget.cancelRecurringAlarm +import com.ichi2.widget.deckpicker.getDeckNameAndStats +import com.ichi2.widget.setRecurringAlarm +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * This widget displays a deck with the respective new, learning, and review card counts. + * It updates every minute and if there is any changes in study queues. + * It allows user to open the reviewer directly by clicking on the deck same as deckpicker. + * It can be configured and reconfigured by holding the widget. + */ +class CardAnalysisWidget : AnalyticsWidgetProvider() { + + companion object { + + /** + * Key used for passing the selected deck ID in the intent extras. + */ + const val EXTRA_SELECTED_DECK_ID = "card_analysis_widget_selected_deck_id" + + /** + * Updates the widget with the deck data. + * + * This method updates the widget view content with the deck data corresponding + * to the provided deck ID. If the deck is deleted, the widget will be cleared. + * + * @param context the context of the application + * @param appWidgetManager the AppWidgetManager instance + * @param appWidgetId the ID of the app widget + * @param deckId the ID of the deck to be displayed in the widget. + */ + fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + deckId: LongArray + ) { + val remoteViews = RemoteViews(context.packageName, R.layout.widget_card_analysis) + + AnkiDroidApp.applicationScope.launch { + val deckData = getDeckNameAndStats(deckId.toList()) + + if (deckData.isEmpty()) { + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + return@launch + } + + val deck = deckData[0] + remoteViews.setTextViewText(R.id.deckNameCardAnalysis, deck.name) + remoteViews.setTextViewText(R.id.deckNew_card_analysis_widget, deck.newCount.toString()) + remoteViews.setTextViewText(R.id.deckDue_card_analysis_widget, deck.reviewCount.toString()) + remoteViews.setTextViewText(R.id.deckLearn_card_analysis_widget, deck.learnCount.toString()) + + val isEmptyDeck = deck.newCount == 0 && deck.reviewCount == 0 && deck.learnCount == 0 + + val intent = if (!isEmptyDeck) { + Intent(context, Reviewer::class.java).apply { + action = Intent.ACTION_VIEW + putExtra("deckId", deck.deckId) + } + } else { + DeckOptions.getIntent(context, deck.deckId) + } + val pendingIntent = PendingIntent.getActivity( + context, + deck.deckId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + remoteViews.setOnClickPendingIntent(R.id.deckNameCardAnalysis, pendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + } + } + + /** + * Updates the Card Analysis Widgets based on the current state of the application. + * It fetches the App Widget IDs and updates each widget with the associated deck ID. + */ + fun updateCardAnalysisWidgets(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + + val provider = ComponentName(context, CardAnalysisWidget::class.java) + Timber.d("Fetching appWidgetIds for provider: $provider") + + val appWidgetIds = appWidgetManager.getAppWidgetIds(provider) + Timber.d("AppWidgetIds to update: ${appWidgetIds.joinToString(", ")}") + + for (appWidgetId in appWidgetIds) { + val widgetPreferences = CardAnalysisWidgetPreferences(context) + val deckId = widgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId) + updateWidget(context, appWidgetManager, appWidgetId, deckId) + } + } + } + + override fun performUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + usageAnalytics: UsageAnalytics + ) { + Timber.d("Performing widget update for appWidgetIds: %s", appWidgetIds) + + val widgetPreferences = CardAnalysisWidgetPreferences(context) + + for (widgetId in appWidgetIds) { + Timber.d("Updating widget with ID: $widgetId") + val selectedDeckId = widgetPreferences.getSelectedDeckIdFromPreferences(widgetId) + + /**Explanation of behavior when selectedDeckId is empty + * If selectedDeckId is empty, the widget will retain the previous deck. + * 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 + * deck ID is provided. This approach prioritizes providing a consistent + * user experience over showing an empty or default state. + */ + Timber.d("Selected deck ID: $selectedDeckId for widget ID: $widgetId") + updateWidget(context, appWidgetManager, widgetId, selectedDeckId) + setRecurringAlarm(context, widgetId, CardAnalysisWidget::class.java) + } + + 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 = CardAnalysisWidgetPreferences(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 selectedDeckId = intent.getLongExtra(EXTRA_SELECTED_DECK_ID, -1L) + + Timber.d("Received ACTION_APPWIDGET_UPDATE with widget ID: $appWidgetId and selectedDeckId: $selectedDeckId") + + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && selectedDeckId != -1L) { + Timber.d("Updating widget with ID: $appWidgetId") + // Wrap selectedDeckId into a LongArray + updateWidget(context, appWidgetManager, appWidgetId, longArrayOf(selectedDeckId)) + 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") + cancelRecurringAlarm(context, appWidgetId, CardAnalysisWidget::class.java) + widgetPreferences.deleteDeckData(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}"), + "CardAnalysisWidget - onReceive", + null, + onlyIfSilent = true + ) + } + } + } + + override fun onDeleted(context: Context?, appWidgetIds: IntArray?) { + if (context == null) { + Timber.w("Context is null in onDeleted") + return + } + + val widgetPreferences = CardAnalysisWidgetPreferences(context) + + appWidgetIds?.forEach { widgetId -> + cancelRecurringAlarm(context, widgetId, CardAnalysisWidget::class.java) + widgetPreferences.deleteDeckData(widgetId) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt new file mode 100644 index 0000000000..4b26a6d162 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt @@ -0,0 +1,370 @@ +/* + * 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.cardanalysis + +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.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.DeckUtils +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 com.ichi2.widget.WidgetConfigScreenAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackbarBuilderProvider { + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + lateinit var deckAdapter: WidgetConfigScreenAdapter + private lateinit var cardAnalysisWidgetPreferences: CardAnalysisWidgetPreferences + + /** + * Maximum number of decks allowed in the widget. + */ + private val MAX_DECKS_ALLOWED = 1 + 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) + + cardAnalysisWidgetPreferences = CardAnalysisWidgetPreferences(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@CardAnalysisWidgetConfig, + 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@CardAnalysisWidgetConfig.deckAdapter + } + + setupDoneButton() + + 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) { + showDiscardChangesDialog() + } + } + } + + 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 showDiscardChangesDialog() { + DiscardChangesDialog.showDialog( + context = this@CardAnalysisWidgetConfig, + positiveMethod = { + // Discard changes and finish the activity + hasUnsavedChanges = false + finish() + } + ) + } + + 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) + } + + private fun setupDoneButton() { + val doneButton = findViewById