0
0
mirror of https://github.com/ankidroid/Anki-Android.git synced 2024-09-19 19:42:17 +02:00

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.
This commit is contained in:
Anoop 2024-08-28 04:07:39 +05:30 committed by David Allison
parent a77d5bbebf
commit c829c3dfd7
17 changed files with 1097 additions and 51 deletions

View File

@ -538,6 +538,33 @@
</intent-filter>
</activity>
<!-- A widget that displays a deck name and number of cards to review on the Android home screen.
The way to add it depends on the phone. It usually consists in a long press on the screen, followed by finding a "widget" button"-->
<receiver
android:name="com.ichi2.widget.cardanalysis.CardAnalysisWidget"
android:label="@string/card_analysis_extra_widget_description"
android:exported="false"
>
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_provider_card_analysis" />
</receiver>
<!-- Configuration view for the CardAnalysisWidget above.
It is opened when adding a new widget and
by configuration button which appears when the widget is hold or resized.-->
<activity
android:name="com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver android:name="com.ichi2.widget.WidgetPermissionReceiver"
android:exported="true">
<intent-filter>

View File

@ -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")
}

View File

@ -0,0 +1,55 @@
/*
* Copyright (c) 2024 Anoop <xenonnn4w@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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)
}
}
}
}

View File

@ -0,0 +1,236 @@
/*
* Copyright (c) 2024 Anoop <xenonnn4w@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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)
}
}
}

View File

@ -0,0 +1,370 @@
/*
* Copyright (c) 2024 Anoop <xenonnn4w@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<RecyclerView>(R.id.recyclerViewSelectedDecks).apply {
layoutManager = LinearLayoutManager(context)
adapter = this@CardAnalysisWidgetConfig.deckAdapter
}
setupDoneButton()
findViewById<FloatingActionButton>(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<FloatingActionButton>(R.id.fabWidgetDeckPicker)
}
private fun setupDoneButton() {
val doneButton = findViewById<Button>(R.id.submit_button)
val saveText = getString(R.string.save).uppercase()
// Set the button text and click listener only once during initialization
doneButton.text = saveText
doneButton.setOnClickListener {
saveSelectedDecksToPreferencesCardAnalysisWidget()
hasUnsavedChanges = false
setUnsavedChanges(false)
val selectedDeckIds = cardAnalysisWidgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId)
val appWidgetManager = AppWidgetManager.getInstance(this)
CardAnalysisWidget.updateWidget(this, appWidgetManager, appWidgetId, selectedDeckIds)
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, resultValue)
sendBroadcast(Intent(this, CardAnalysisWidget::class.java))
finish()
}
// Initially set the visibility based on the number of selected decks
updateDoneButtonVisibility()
}
private fun updateDoneButtonVisibility() {
val doneButton = findViewById<Button>(R.id.submit_button)
doneButton.isVisible = deckAdapter.itemCount != 0
}
/** Updates the visibility of the FloatingActionButton based on the number of selected decks */
private fun updateFabVisibility() {
lifecycleScope.launch {
// Directly check if there's exactly one deck selected
val selectedDeckCount = deckAdapter.itemCount
// Find the FloatingActionButton by its ID
val fab = findViewById<FloatingActionButton>(R.id.fabWidgetDeckPicker)
// Make the FAB visible only if no deck is selected (allow adding one deck)
fab.isVisible = selectedDeckCount == 0
}
}
private suspend fun isDefaultDeckEmpty(): Boolean {
return DeckUtils.isDefaultDeckEmpty()
}
/** Updates the view according to the saved preference for appWidgetId.*/
fun updateViewWithSavedPreferences() {
val selectedDeckIds = cardAnalysisWidgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId)
if (selectedDeckIds.isNotEmpty()) {
lifecycleScope.launch {
val decks = fetchDecks()
val selectedDecks = decks.filter { it.deckId in selectedDeckIds }
selectedDecks.forEach { deckAdapter.addDeck(it) }
updateViewVisibility()
updateFabVisibility()
setupDoneButton()
}
}
}
/** Asynchronously displays the list of deck in the selection dialog. */
private fun showDeckSelectionDialog() {
lifecycleScope.launch {
val decks = fetchDecks()
displayDeckSelectionDialog(decks)
}
}
/** Returns the list of standard deck. */
private suspend fun fetchDecks(): List<SelectableDeck> {
return withContext(Dispatchers.IO) {
SelectableDeck.fromCollection(includeFiltered = false)
}
}
/** Displays the deck selection dialog with the provided list of decks. */
private fun displayDeckSelectionDialog(decks: List<SelectableDeck>) {
val dialog = DeckSelectionDialog.newInstance(
title = getString(R.string.select_deck_title),
summaryMessage = null,
keepRestoreDefaultButton = false,
decks = decks
)
dialog.show(supportFragmentManager, "DeckSelectionDialog")
}
/** Called when a deck is selected from the deck selection dialog. */
override fun onDeckSelected(deck: SelectableDeck?) {
if (deck == null) {
return
}
// Check if the deck is being added to a fully occupied selection
if (deckAdapter.itemCount >= MAX_DECKS_ALLOWED) {
showSnackbar(R.string.deck_limit_one)
} else {
// Add the deck and update views
deckAdapter.addDeck(deck)
updateViewVisibility()
updateFabVisibility()
setupDoneButton()
hasUnsavedChanges = true
setUnsavedChanges(true)
if (deckAdapter.itemCount == MAX_DECKS_ALLOWED) {
showSnackbar(R.string.deck_limit_one) // Show Snackbar if the limit is now reached
}
}
}
/** Updates the visibility of the "no decks" placeholder and the widget configuration container */
fun updateViewVisibility() {
val noDecksPlaceholder = findViewById<View>(R.id.no_decks_placeholder)
val widgetConfigContainer = findViewById<View>(R.id.widgetConfigContainer)
noDecksPlaceholder.isVisible = deckAdapter.itemCount == 0
widgetConfigContainer.isVisible = deckAdapter.itemCount > 0
}
fun saveSelectedDecksToPreferencesCardAnalysisWidget() {
val selectedDecks = deckAdapter.deckIds.map { it }
cardAnalysisWidgetPreferences.saveSelectedDeck(appWidgetId, selectedDecks.map { it.toString() })
val updateIntent = Intent(this, CardAnalysisWidget::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
putExtra("card_analysis_widget_selected_deck_ids", selectedDecks.toList().toLongArray())
}
sendBroadcast(updateIntent)
}
/** BroadcastReceiver to handle widget removal. */
private val widgetRemovedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != AppWidgetManager.ACTION_APPWIDGET_DELETED) {
return
}
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
return
}
context?.let { cardAnalysisWidgetPreferences.deleteDeckData(appWidgetId) }
}
}
private suspend fun isCollectionEmpty(): Boolean {
val tree = withCol { sched.deckDueTree() }
if (tree.children.size == 1 && tree.children[0].did == 1L) {
return isDefaultDeckEmpty()
}
return false
}
}
fun ContextWrapper.unregisterReceiverSilently(receiver: BroadcastReceiver) {
try {
unregisterReceiver(receiver)
} catch (e: IllegalArgumentException) {
Timber.d(e, "unregisterReceiverSilently")
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright (c) 2024 Anoop <xenonnn4w@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.ichi2.widget.cardanalysis
import android.content.Context
import androidx.core.content.edit
class CardAnalysisWidgetPreferences(context: Context) {
/**
* Prefix for the SharedPreferences key used to store the selected deck for the Card Analysis Widget.
* The full key is constructed by appending the appWidgetId to this prefix, ensuring that each
* widget instance has a unique key. This approach helps prevent typos and ensures consistency
* across the codebase when accessing or modifying the stored deck selections.
*/
private val cardAnalysisWidgetSharedPreferences = context.getSharedPreferences("CardAnalysisExtraWidgetPrefs", Context.MODE_PRIVATE)
/**
* Deletes the selected deck ID from the shared preferences for the given widget ID.
*/
fun deleteDeckData(appWidgetId: Int) {
cardAnalysisWidgetSharedPreferences.edit {
remove(getCardAnalysisExtraWidgetKey(appWidgetId))
}
}
fun getSelectedDeckIdFromPreferences(appWidgetId: Int): LongArray {
val selectedDeckString = cardAnalysisWidgetSharedPreferences.getString(
getCardAnalysisExtraWidgetKey(appWidgetId),
""
)
return if (!selectedDeckString.isNullOrEmpty()) {
selectedDeckString.split(",").map { it.toLong() }.toLongArray()
} else {
longArrayOf()
}
}
fun saveSelectedDeck(appWidgetId: Int, selectedDeck: List<String>) {
cardAnalysisWidgetSharedPreferences.edit {
putString(getCardAnalysisExtraWidgetKey(appWidgetId), selectedDeck.joinToString(","))
}
}
}
/**
* Generates the key for the shared preferences for the given widget ID.
*/
private fun getCardAnalysisExtraWidgetKey(appWidgetId: Int): String {
return "card_analysis_extra_widget_selected_deck_$appWidgetId"
}

View File

@ -18,6 +18,7 @@ package com.ichi2.widget.deckpicker
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
@ -31,7 +32,6 @@ 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.WidgetPreferences
import com.ichi2.widget.cancelRecurringAlarm
import com.ichi2.widget.setRecurringAlarm
import kotlinx.coroutines.launch
@ -67,11 +67,6 @@ data class DeckPickerWidgetData(
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
/**
* Key used for passing the selected deck IDs in the intent extras.
@ -159,8 +154,8 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
Timber.d("AppWidgetIds to update: ${appWidgetIds.joinToString(", ")}")
for (appWidgetId in appWidgetIds) {
val widgetPreferences = WidgetPreferences(context)
val deckIds = widgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(appWidgetId)
val widgetPreferences = DeckPickerWidgetPreferences(context)
val deckIds = widgetPreferences.getSelectedDeckIdsFromPreferences(appWidgetId)
updateWidget(context, appWidgetManager, appWidgetId, deckIds)
}
}
@ -172,13 +167,13 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
appWidgetIds: IntArray,
usageAnalytics: UsageAnalytics
) {
Timber.d("Performing widget update for appWidgetIds: ${appWidgetIds.joinToString(", ")}")
Timber.d("Performing widget update for appWidgetIds: %s", appWidgetIds)
val widgetPreferences = WidgetPreferences(context)
val widgetPreferences = DeckPickerWidgetPreferences(context)
for (widgetId in appWidgetIds) {
Timber.d("Updating widget with ID: $widgetId")
val selectedDeckIds = widgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(widgetId)
val selectedDeckIds = widgetPreferences.getSelectedDeckIdsFromPreferences(widgetId)
/**Explanation of behavior when selectedDeckIds is empty
* If selectedDeckIds is empty, the widget will retain the previous deck list.
@ -204,7 +199,7 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
}
super.onReceive(context, intent)
val widgetPreferences = WidgetPreferences(context)
val widgetPreferences = DeckPickerWidgetPreferences(context)
when (intent.action) {
ACTION_APPWIDGET_UPDATE -> {
@ -236,7 +231,7 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
Timber.d("Deleting widget with ID: $appWidgetId")
cancelRecurringAlarm(context, appWidgetId, DeckPickerWidget::class.java)
widgetPreferences.deleteDeckPickerWidgetData(appWidgetId)
widgetPreferences.deleteDeckData(appWidgetId)
} else {
Timber.e("Invalid widget ID received in ACTION_APPWIDGET_DELETED")
}
@ -265,11 +260,11 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
return
}
val widgetPreferences = WidgetPreferences(context)
val widgetPreferences = DeckPickerWidgetPreferences(context)
appWidgetIds?.forEach { widgetId ->
cancelRecurringAlarm(context, widgetId, DeckPickerWidget::class.java)
widgetPreferences.deleteDeckPickerWidgetData(widgetId)
widgetPreferences.deleteDeckData(widgetId)
}
}
}

View File

@ -35,6 +35,7 @@ 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
@ -45,7 +46,6 @@ import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
import com.ichi2.anki.snackbar.SnackbarBuilder
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.widget.WidgetConfigScreenAdapter
import com.ichi2.widget.WidgetPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -61,7 +61,7 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackb
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
lateinit var deckAdapter: WidgetConfigScreenAdapter
private lateinit var deckPickerWidgetPreferences: WidgetPreferences
private lateinit var deckPickerWidgetPreferences: DeckPickerWidgetPreferences
/**
* Maximum number of decks allowed in the widget.
@ -84,7 +84,7 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackb
setContentView(R.layout.widget_deck_picker_config)
deckPickerWidgetPreferences = WidgetPreferences(this)
deckPickerWidgetPreferences = DeckPickerWidgetPreferences(this)
appWidgetId = intent.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
@ -225,7 +225,7 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackb
hasUnsavedChanges = false
setUnsavedChanges(false)
val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(appWidgetId)
val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferences(appWidgetId)
val appWidgetManager = AppWidgetManager.getInstance(this)
DeckPickerWidget.updateWidget(this, appWidgetManager, appWidgetId, selectedDeckIds)
@ -281,18 +281,12 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackb
}
private suspend fun isDefaultDeckEmpty(): Boolean {
val defaultDeckId = 1L
val decks = withContext(Dispatchers.IO) {
withCol { decks }
}
val deckIds = decks.deckAndChildIds(defaultDeckId)
val totalCardCount = decks.cardCount(*deckIds.toLongArray(), includeSubdecks = true)
return totalCardCount == 0
return DeckUtils.isDefaultDeckEmpty()
}
/** Updates the view according to the saved preference for appWidgetId.*/
fun updateViewWithSavedPreferences() {
val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(appWidgetId)
val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferences(appWidgetId)
if (selectedDeckIds.isNotEmpty()) {
lifecycleScope.launch {
val decks = fetchDecks()
@ -447,7 +441,7 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackb
return
}
context?.let { deckPickerWidgetPreferences.deleteDeckPickerWidgetData(appWidgetId) }
context?.let { deckPickerWidgetPreferences.deleteDeckData(appWidgetId) }
}
}

View File

@ -14,18 +14,12 @@
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.widget
package com.ichi2.widget.deckpicker
import android.content.Context
import androidx.core.content.edit
/**
* This class is currently used for the Deck Picker Widget but is designed to be extendable
* for use with other widgets, such as the Card Analysis Widget, in the future.
*
* @param context the context used to access the shared preferences
*/
class WidgetPreferences(context: Context) {
class DeckPickerWidgetPreferences(context: Context) {
/**
* Prefix for the SharedPreferences key used to store the selected decks for the DeckPickerWidget.
@ -39,7 +33,7 @@ class WidgetPreferences(context: Context) {
/**
* Deletes the selected deck IDs from the shared preferences for the given widget ID.
*/
fun deleteDeckPickerWidgetData(appWidgetId: Int) {
fun deleteDeckData(appWidgetId: Int) {
deckPickerSharedPreferences.edit {
remove(getDeckPickerWidgetKey(appWidgetId))
}
@ -49,7 +43,7 @@ class WidgetPreferences(context: Context) {
* Retrieves the selected deck IDs from the shared preferences for the given widget ID.
* Note: There's no guarantee that these IDs still represent decks that exist at the time of execution.
*/
fun getSelectedDeckIdsFromPreferencesDeckPickerWidget(appWidgetId: Int): LongArray {
fun getSelectedDeckIdsFromPreferences(appWidgetId: Int): LongArray {
val selectedDecksString = deckPickerSharedPreferences.getString(getDeckPickerWidgetKey(appWidgetId), "")
return if (!selectedDecksString.isNullOrEmpty()) {
selectedDecksString.split(",").map { it.toLong() }.toLongArray()

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -0,0 +1,65 @@
<RelativeLayout android:id="@+id/widget_deck_picker"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorPrimaryInverse"
android:clickable="true"
android:focusable="false"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<LinearLayout
android:id="@+id/cardAnalysisDataHolder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimaryContainer"
android:orientation="horizontal"
android:padding="20dp">
<TextView
android:id="@+id/deckNew_card_analysis_widget"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textColor="@color/flag_reviewer_blue"
android:textSize="24sp"
android:textStyle="bold"
tools:text="50" />
<TextView
android:id="@+id/deckLearn_card_analysis_widget"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textColor="@color/flag_reviewer_red"
android:textSize="24sp"
android:textStyle="bold"
tools:text="12" />
<TextView
android:id="@+id/deckDue_card_analysis_widget"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textColor="@color/flag_reviewer_green"
android:textSize="24sp"
android:textStyle="bold"
tools:text="57" />
</LinearLayout>
<TextView
android:id="@+id/deckNameCardAnalysis"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="70dp"
android:gravity="center"
android:padding="20dp"
android:paddingBottom="5dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="24sp"
tools:text="Default" />
</RelativeLayout>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8" ?>
<LinearLayout xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimaryInverse"
android:orientation="vertical"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimaryContainer"
android:orientation="horizontal"
android:padding="20dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="85"
android:textColor="@color/flag_reviewer_blue"
android:textSize="24sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="38"
android:textColor="@color/flag_reviewer_red"
android:textSize="24sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="84"
android:textColor="@color/flag_reviewer_green"
android:textSize="24sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="20dp"
android:paddingBottom="5dp"
android:text="@string/deck1Name_deck_picker_widget"
android:textColor="?android:attr/textColorPrimary"
android:textSize="24sp" />
</LinearLayout>

View File

@ -39,8 +39,9 @@
<string name="widget_add_note_button">Add new AnkiDroid note</string>
<string name="deck_picker_widget_description">Deck Picker</string>
<string name="card_analysis_extra_widget_description">Card Analysis</string>
<!-- Strings to explain usage in Deck Picker Widget Configuration screen -->
<!-- Strings to explain usage in Deck Picker and Card Analysis Widget Configuration screen -->
<string name="select_deck_title" comment="Title for Deck Selection Dialog">Select decks</string>
<string name="no_selected_deck_placeholder_title" comment="Placeholder title when no decks are selected">Select decks to display in the widget. Select decks with the + icon.</string>
<string name="deck_removed_from_widget" comment="Snackbar when deck is removed from widget">Deck removed</string>
@ -49,5 +50,6 @@
<item quantity="one">You can select up to %d deck.</item>
<item quantity="other">You can select up to %d decks.</item>
</plurals>
<string name="deck_limit_one">You may only select one deck</string>
</resources>

View File

@ -0,0 +1,28 @@
<!-- JPG type of file used in previewImage property because the SVG format is not supported on all devices.
The widths and heights parameters are determined as follows:
The default is 3 cells in width and 2 in height.
Following https://developer.android.com/develop/ui/views/appwidgets/layouts#anatomy_determining_size
we used the portrait mode cell size for the width and the landscape mode cell size for the height. Leading to:
* height between 102 and 315
* width 203 and 276 -->
<!-- TODO: Use updatePeriodMillis instead of the 10-minute alarm for simpler widget updates.-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/widget_card_analysis"
android:initialLayout="@layout/widget_card_analysis"
android:configure="com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig"
android:widgetFeatures="reconfigurable"
android:minWidth="203dp"
android:minHeight="102dp"
android:minResizeWidth="203dp"
android:minResizeHeight="102dp"
android:maxResizeWidth="276dp"
android:maxResizeHeight="315dp"
android:previewImage="@drawable/widget_card_analysis_drawable"
android:previewLayout="@layout/widget_card_analysis_drawable_v31"
android:resizeMode="horizontal|vertical"
android:targetCellHeight="2"
android:targetCellWidth="3"
android:widgetCategory="home_screen"
/>

View File

@ -0,0 +1,155 @@
/*
* Copyright (c) 2024 Anoop <xenonnn4w@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.widget.cardanalysis
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.dialogs.DeckSelectionDialog
import com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig
import com.ichi2.widget.cardanalysis.CardAnalysisWidgetPreferences
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
@RunWith(AndroidJUnit4::class)
class CardAnalysisWidgetConfigTest : RobolectricTest() {
private lateinit var activity: CardAnalysisWidgetConfig
private lateinit var widgetPreferences: CardAnalysisWidgetPreferences
/**
* Sets up the test environment before each test.
*
* Initializes the `CardAnalysisWidgetConfig` activity and associated components like
* `WidgetPreferences`. This setup is executed before each test method.
*/
@Before
override fun setUp() {
super.setUp()
val intent = Intent(ApplicationProvider.getApplicationContext(), CardAnalysisWidgetConfig::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 1)
}
activity = Robolectric.buildActivity(CardAnalysisWidgetConfig::class.java, intent)
.create()
.start()
.resume()
.get()
// Initialize widget preferences
widgetPreferences = CardAnalysisWidgetPreferences(ApplicationProvider.getApplicationContext())
// Ensure deckAdapter is initialized
activity.initializeUIComponents()
}
/**
* Tests the functionality of saving selected decks to preferences.
*
* This test adds a deck to the adapter and verifies if it gets correctly saved to the
* `WidgetPreferences`.
*/
@Test
fun testSaveSelectedDecksToPreferences() {
// Add decks to adapter
val deck1 = DeckSelectionDialog.SelectableDeck(1, "Deck 1")
activity.deckAdapter.addDeck(deck1)
// Save selected decks
activity.saveSelectedDecksToPreferencesCardAnalysisWidget()
// Verify saved decks
val selectedDeckIds = widgetPreferences.getSelectedDeckIdFromPreferences(1).toList()
assertThat(selectedDeckIds.contains(deck1.deckId), equalTo(true))
}
/**
* Tests the loading of saved preferences into the activity's view.
*
* This test saves decks to preferences, then loads them into the activity and checks if the
* `RecyclerView` displays the correct number of items based on the saved preferences.
*/
@Test
fun testLoadSavedPreferences() {
// Save decks to preferences
val deckIds = listOf(1L)
widgetPreferences.saveSelectedDeck(1, deckIds.map { it.toString() })
// Load preferences
activity.updateViewWithSavedPreferences()
// Ensure all tasks on the UI thread are completed
Robolectric.flushForegroundThreadScheduler()
// Get the RecyclerView and its adapter
val recyclerView = activity.findViewById<RecyclerView>(R.id.recyclerViewSelectedDecks)
val adapter = recyclerView.adapter
// Verify the adapter has the correct item count
assertThat(adapter?.itemCount, equalTo(deckIds.size))
}
/**
* Tests the visibility of different views based on the selected decks.
*
* This test checks the visibility of the placeholder and configuration container views
* before and after adding a deck.
*/
@Test
fun testUpdateViewVisibility() {
val noDecksPlaceholder = activity.findViewById<View>(R.id.no_decks_placeholder)
val widgetConfigContainer = activity.findViewById<View>(R.id.widgetConfigContainer)
// Initially, no decks should be selected
activity.updateViewVisibility()
assertThat(noDecksPlaceholder.visibility, equalTo(View.VISIBLE))
assertThat(widgetConfigContainer.visibility, equalTo(View.GONE))
// Add a deck and update view visibility
val deck = DeckSelectionDialog.SelectableDeck(1, "Deck 1")
activity.deckAdapter.addDeck(deck)
activity.updateViewVisibility()
assertThat(noDecksPlaceholder.visibility, equalTo(View.GONE))
assertThat(widgetConfigContainer.visibility, equalTo(View.VISIBLE))
}
/**
* Tests the selection of a deck.
*
* This test verifies that when a deck is selected, it gets added to the adapter and displayed
* in the `RecyclerView`.
*/
@Test
fun testOnDeckSelected() {
val deck = DeckSelectionDialog.SelectableDeck(1, "Deck 1")
activity.onDeckSelected(deck)
// Verify deck is added to adapter
val recyclerView = activity.findViewById<RecyclerView>(R.id.recyclerViewSelectedDecks)
assertThat(recyclerView.adapter?.itemCount, equalTo(1))
}
}

View File

@ -19,16 +19,14 @@ package com.ichi2.anki.widget.deckpicker
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.dialogs.DeckSelectionDialog
import com.ichi2.widget.WidgetPreferences
import com.ichi2.widget.deckpicker.DeckPickerWidgetConfig
import com.ichi2.widget.deckpicker.DeckPickerWidgetPreferences
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Before
@ -40,14 +38,13 @@ import org.robolectric.Robolectric
class DeckPickerWidgetConfigTest : RobolectricTest() {
private lateinit var activity: DeckPickerWidgetConfig
private lateinit var lifecycle: LifecycleRegistry
private lateinit var widgetPreferences: WidgetPreferences
private lateinit var widgetPreferences: DeckPickerWidgetPreferences
/**
* Sets up the test environment before each test.
*
* Initializes the `DeckPickerWidgetConfig` activity and associated components like
* `LifecycleRegistry` and `WidgetPreferences`. This setup is executed before each test method.
* `WidgetPreferences`. This setup is executed before each test method.
*/
@Before
override fun setUp() {
@ -62,10 +59,7 @@ class DeckPickerWidgetConfigTest : RobolectricTest() {
.resume()
.get()
lifecycle = LifecycleRegistry(activity)
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
widgetPreferences = WidgetPreferences(ApplicationProvider.getApplicationContext())
widgetPreferences = DeckPickerWidgetPreferences(ApplicationProvider.getApplicationContext())
// Ensure deckAdapter is initialized
activity.initializeUIComponents()
@ -87,7 +81,7 @@ class DeckPickerWidgetConfigTest : RobolectricTest() {
activity.saveSelectedDecksToPreferencesDeckPickerWidget()
// Verify saved decks
val selectedDeckIds = widgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(1).toList()
val selectedDeckIds = widgetPreferences.getSelectedDeckIdsFromPreferences(1).toList()
assertThat(selectedDeckIds.contains(deck1.deckId), equalTo(true))
}

View File

@ -47,6 +47,7 @@ import com.ichi2.anki.services.ReminderService.Companion.getReviewDeckIntent
import com.ichi2.anki.ui.windows.managespace.ManageSpaceActivity
import com.ichi2.anki.ui.windows.permissions.PermissionsActivity
import com.ichi2.testutils.ActivityList.ActivityLaunchParam.Companion.get
import com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig
import com.ichi2.widget.deckpicker.DeckPickerWidgetConfig
import org.robolectric.Robolectric
import org.robolectric.android.controller.ActivityController
@ -90,7 +91,8 @@ object ActivityList {
get(CardViewerActivity::class.java),
get(InstantNoteEditorActivity::class.java),
get(MultimediaActivity::class.java),
get(DeckPickerWidgetConfig::class.java)
get(DeckPickerWidgetConfig::class.java),
get(CardAnalysisWidgetConfig::class.java)
)
}