Skip to content

Commit 6d3d0ca

Browse files
xenonnn4wdavid-allison
authored andcommitted
Refactor DeckUtils and CardAnalysisWidget for Improved Deck Handling
1 parent 4499c84 commit 6d3d0ca

File tree

7 files changed

+105
-103
lines changed

7 files changed

+105
-103
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt

Lines changed: 36 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,57 +17,43 @@
1717
package com.ichi2.anki
1818

1919
import com.ichi2.anki.CollectionManager.withCol
20-
import com.ichi2.libanki.Decks
21-
import kotlinx.coroutines.Dispatchers
22-
import kotlinx.coroutines.withContext
20+
import com.ichi2.libanki.Collection
21+
import com.ichi2.libanki.Consts
2322

24-
object DeckUtils {
25-
26-
/**
27-
* Checks if a given deck, including its subdecks if specified, is empty.
28-
*
29-
* @param decks The [Decks] instance containing the decks to check.
30-
* @param deckId The ID of the deck to check.
31-
* @param includeSubdecks If true, includes subdecks in the check. Default is true.
32-
* @return `true` if the deck (and subdecks if specified) is empty, otherwise `false`.
33-
*/
34-
private fun isDeckEmpty(decks: Decks, deckId: Long, includeSubdecks: Boolean = true): Boolean {
35-
val deckIds = decks.deckAndChildIds(deckId)
36-
val totalCardCount = decks.cardCount(*deckIds.toLongArray(), includeSubdecks = includeSubdecks)
37-
return totalCardCount == 0
38-
}
23+
/**
24+
* Checks if a given deck, including its subdecks if specified, is empty.
25+
*
26+
* @param deckId The ID of the deck to check.
27+
* @param includeSubdecks If true, includes subdecks in the check. Default is true.
28+
* @return `true` if the deck (and subdecks if specified) is empty, otherwise `false`.
29+
*/
30+
private fun Collection.isDeckEmpty(deckId: Long, includeSubdecks: Boolean = true): Boolean {
31+
val deckIds = decks.deckAndChildIds(deckId)
32+
val totalCardCount = decks.cardCount(*deckIds.toLongArray(), includeSubdecks = includeSubdecks)
33+
return totalCardCount == 0
34+
}
3935

40-
/**
41-
* Checks if the default deck is empty.
42-
*
43-
* This method runs on an IO thread and accesses the collection to determine if the default deck (with ID 1) is empty.
44-
*
45-
* @return `true` if the default deck is empty, otherwise `false`.
46-
*/
47-
suspend fun isDefaultDeckEmpty(): Boolean {
48-
val defaultDeckId = 1L
49-
return withContext(Dispatchers.IO) {
50-
withCol {
51-
isDeckEmpty(decks, defaultDeckId)
52-
}
53-
}
54-
}
36+
/**
37+
* Checks if the default deck is empty.
38+
*
39+
* This method runs on an IO thread and accesses the collection to determine if the default deck (with ID 1) is empty.
40+
*
41+
* @return `true` if the default deck is empty, otherwise `false`.
42+
*/
43+
suspend fun isDefaultDeckEmpty(): Boolean = withCol { isDeckEmpty(Consts.DEFAULT_DECK_ID) }
5544

56-
/**
57-
* Returns whether the deck picker displays any deck.
58-
* Technically, it means that there is a non default deck, or that the default deck is non-empty.
59-
*
60-
* This function is specifically implemented to address an issue where the default deck
61-
* isn't handled correctly when a second deck is added to the
62-
* collection. In this case, the deck tree may incorrectly appear as non-empty when it contains
63-
* only the default deck and no other cards.
64-
*
65-
*/
66-
suspend fun isCollectionEmpty(): Boolean {
67-
val tree = withCol { sched.deckDueTree() }
68-
if (tree.children.size == 1 && tree.children[0].did == 1L) {
69-
return isDefaultDeckEmpty()
70-
}
71-
return false
72-
}
45+
/**
46+
* Returns whether the deck picker displays any deck.
47+
* Technically, it means that there is a non-default deck, or that the default deck is non-empty.
48+
*
49+
* This function is specifically implemented to address an issue where the default deck
50+
* isn't handled correctly when a second deck is added to the
51+
* collection. In this case, the deck tree may incorrectly appear as non-empty when it contains
52+
* only the default deck and no other cards.
53+
*
54+
*/
55+
suspend fun isCollectionEmpty(): Boolean {
56+
val tree = withCol { sched.deckDueTree() }
57+
val onlyDefaultDeckAvailable = tree.children.singleOrNull()?.did == Consts.DEFAULT_DECK_ID
58+
return onlyDefaultDeckAvailable && isDefaultDeckEmpty()
7359
}

AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ import android.view.View
2626
import android.widget.RemoteViews
2727
import com.ichi2.anki.AnkiDroidApp
2828
import com.ichi2.anki.CrashReportService
29-
import com.ichi2.anki.DeckUtils
3029
import com.ichi2.anki.R
3130
import com.ichi2.anki.Reviewer
3231
import com.ichi2.anki.analytics.UsageAnalytics
32+
import com.ichi2.anki.isCollectionEmpty
3333
import com.ichi2.anki.pages.DeckOptions
3434
import com.ichi2.libanki.DeckId
35+
import com.ichi2.libanki.Decks.Companion.NOT_FOUND_DECK_ID
3536
import com.ichi2.widget.ACTION_UPDATE_WIDGET
3637
import com.ichi2.widget.AnalyticsWidgetProvider
3738
import com.ichi2.widget.cancelRecurringAlarm
@@ -60,43 +61,53 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() {
6061
* Updates the widget with the deck data.
6162
*
6263
* This method updates the widget view content with the deck data corresponding
63-
* to the provided deck ID. If the deck is deleted, the widget will be cleared.
64+
* to the provided deck ID. If the deck is deleted, the widget will be show a message "Missing deck. Please reconfigure".
6465
*
6566
* @param context the context of the application
6667
* @param appWidgetManager the AppWidgetManager instance
6768
* @param appWidgetId the ID of the app widget
68-
* @param deckId the ID of the deck to be displayed in the widget.
6969
*/
7070
fun updateWidget(
7171
context: Context,
7272
appWidgetManager: AppWidgetManager,
73-
appWidgetId: Int,
74-
deckId: DeckId?
73+
appWidgetId: Int
7574
) {
75+
val deckId = getDeckIdForWidget(context, appWidgetId)
7676
val remoteViews = RemoteViews(context.packageName, R.layout.widget_card_analysis)
77-
if (deckId == null) {
77+
78+
if (deckId == NOT_FOUND_DECK_ID) {
79+
// If deckId is null, it means no deck was selected or the selected deck was deleted.
80+
// In this case, we don't save the null value to preferences because we want to
81+
// keep the previous deck ID if the user reconfigures the widget later.
82+
// Instead, we show a message prompting the user to reconfigure the widget.
7883
showMissingDeck(context, appWidgetManager, appWidgetId, remoteViews)
7984
return
8085
}
86+
8187
AnkiDroidApp.applicationScope.launch {
82-
val isCollectionEmpty = DeckUtils.isCollectionEmpty()
88+
val isCollectionEmpty = isCollectionEmpty()
8389
if (isCollectionEmpty) {
8490
showCollectionDeck(context, appWidgetManager, appWidgetId, remoteViews)
8591
return@launch
8692
}
8793

8894
val deckData = getDeckNameAndStats(deckId)
89-
9095
if (deckData == null) {
91-
// If the deck was deleted, clear the stored deck ID
92-
CardAnalysisWidgetPreferences(context).saveSelectedDeck(appWidgetId, null)
96+
// The deck was found but no data could be fetched, so update the preferences to remove the deck.
97+
// This ensures that the widget does not retain a reference to a non-existent or invalid deck.
98+
CardAnalysisWidgetPreferences(context).saveSelectedDeck(appWidgetId, NOT_FOUND_DECK_ID)
9399
showMissingDeck(context, appWidgetManager, appWidgetId, remoteViews)
94100
return@launch
95101
}
96102
showDeck(context, appWidgetManager, appWidgetId, remoteViews, deckData)
97103
}
98104
}
99105

106+
private fun getDeckIdForWidget(context: Context, appWidgetId: Int): DeckId {
107+
val widgetPreferences = CardAnalysisWidgetPreferences(context)
108+
return widgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId) ?: NOT_FOUND_DECK_ID
109+
}
110+
100111
private fun showCollectionDeck(
101112
context: Context,
102113
appWidgetManager: AppWidgetManager,
@@ -160,6 +171,7 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() {
160171
Intent(context, Reviewer::class.java).apply {
161172
action = Intent.ACTION_VIEW
162173
putExtra("deckId", deckData.deckId)
174+
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
163175
}
164176
} else {
165177
DeckOptions.getIntent(context, deckData.deckId)
@@ -189,9 +201,8 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() {
189201
Timber.d("AppWidgetIds to update: ${appWidgetIds.joinToString(", ")}")
190202

191203
for (appWidgetId in appWidgetIds) {
192-
val widgetPreferences = CardAnalysisWidgetPreferences(context)
193-
val deckId = widgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId)
194-
updateWidget(context, appWidgetManager, appWidgetId, deckId)
204+
getDeckIdForWidget(context, appWidgetId)
205+
updateWidget(context, appWidgetManager, appWidgetId)
195206
}
196207
}
197208
}
@@ -204,21 +215,25 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() {
204215
) {
205216
Timber.d("Performing widget update for appWidgetIds: %s", appWidgetIds)
206217

207-
val widgetPreferences = CardAnalysisWidgetPreferences(context)
208-
209218
for (widgetId in appWidgetIds) {
210219
Timber.d("Updating widget with ID: $widgetId")
211-
val selectedDeckId = widgetPreferences.getSelectedDeckIdFromPreferences(widgetId)
212220

213-
/**Explanation of behavior when selectedDeckId is empty
221+
// Get the selected deck ID internally
222+
val selectedDeckId = getDeckIdForWidget(context, widgetId)
223+
224+
/**
225+
* Explanation of behavior when selectedDeckId is empty
214226
* If selectedDeckId is empty, the widget will retain the previous deck.
215227
* This behavior ensures that the widget does not display an empty view, which could be
216228
* confusing to the user. Instead, it maintains the last known state until a new valid
217229
* deck ID is provided. This approach prioritizes providing a consistent
218230
* user experience over showing an empty or default state.
219231
*/
220232
Timber.d("Selected deck ID: $selectedDeckId for widget ID: $widgetId")
221-
updateWidget(context, appWidgetManager, widgetId, selectedDeckId)
233+
234+
// Update the widget with the selected deck ID
235+
updateWidget(context, appWidgetManager, widgetId)
236+
// Set the recurring alarm for the widget
222237
setRecurringAlarm(context, widgetId, CardAnalysisWidget::class.java)
223238
}
224239

@@ -244,19 +259,23 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() {
244259

245260
Timber.d("Received ACTION_APPWIDGET_UPDATE with widget ID: $appWidgetId and selectedDeckId: $selectedDeckId")
246261

247-
if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && selectedDeckId != -1L) {
262+
if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
248263
Timber.d("Updating widget with ID: $appWidgetId")
249-
// Wrap selectedDeckId into a LongArray
250-
updateWidget(context, appWidgetManager, appWidgetId, selectedDeckId)
264+
265+
// Update the widget using the internally fetched deck ID
266+
updateWidget(context, appWidgetManager, appWidgetId)
267+
251268
Timber.d("Widget update process completed for widget ID: $appWidgetId")
252269
}
253270
}
254-
// This custom action is received to update a specific widget.
255-
// It is triggered by the setRecurringAlarm method to refresh the widget's data periodically.
271+
// Custom action to update a specific widget, triggered by the setRecurringAlarm method
256272
ACTION_UPDATE_WIDGET -> {
257273
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
258274
if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
259275
Timber.d("Received ACTION_UPDATE_WIDGET for widget ID: $appWidgetId")
276+
277+
// Update the widget using the internally fetched deck ID
278+
updateWidget(context, AppWidgetManager.getInstance(context), appWidgetId)
260279
}
261280
}
262281
AppWidgetManager.ACTION_APPWIDGET_DELETED -> {

AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,20 @@ import android.os.Bundle
2626
import android.view.View
2727
import android.widget.Button
2828
import androidx.activity.OnBackPressedCallback
29+
import androidx.annotation.StringRes
2930
import androidx.core.view.isVisible
3031
import androidx.lifecycle.lifecycleScope
3132
import androidx.recyclerview.widget.LinearLayoutManager
3233
import androidx.recyclerview.widget.RecyclerView
3334
import com.google.android.material.floatingactionbutton.FloatingActionButton
3435
import com.google.android.material.snackbar.Snackbar
3536
import com.ichi2.anki.AnkiActivity
36-
import com.ichi2.anki.DeckUtils.isCollectionEmpty
3737
import com.ichi2.anki.R
3838
import com.ichi2.anki.dialogs.DeckSelectionDialog
3939
import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener
4040
import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
4141
import com.ichi2.anki.dialogs.DiscardChangesDialog
42+
import com.ichi2.anki.isCollectionEmpty
4243
import com.ichi2.anki.showThemedToast
4344
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
4445
import com.ichi2.anki.snackbar.SnackbarBuilder
@@ -49,6 +50,8 @@ import kotlinx.coroutines.launch
4950
import kotlinx.coroutines.withContext
5051
import timber.log.Timber
5152

53+
// TODO: Ensure that the Deck Selection Dialog does not close automatically while the user is interacting with it.
54+
5255
class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackbarBuilderProvider {
5356

5457
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
@@ -62,6 +65,7 @@ class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnac
6265
private var hasUnsavedChanges = false
6366
private var isAdapterObserverRegistered = false
6467
private lateinit var onBackPressedCallback: OnBackPressedCallback
68+
private val EXTRA_SELECTED_DECK_IDS = "card_analysis_widget_selected_deck_ids"
6569

6670
override fun onCreate(savedInstanceState: Bundle?) {
6771
if (showedActivityFailedScreen(savedInstanceState)) {
@@ -117,7 +121,7 @@ class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnac
117121
)
118122
}
119123

120-
fun showSnackbar(messageResId: Int) {
124+
fun showSnackbar(@StringRes messageResId: Int) {
121125
showSnackbar(getString(messageResId))
122126
}
123127

@@ -128,7 +132,6 @@ class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnac
128132
updateViewVisibility()
129133
updateFabVisibility()
130134
updateSubmitButtonText()
131-
hasUnsavedChanges = true
132135
setUnsavedChanges(true)
133136
}
134137

@@ -156,9 +159,9 @@ class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnac
156159

157160
registerReceiver(widgetRemovedReceiver, IntentFilter(AppWidgetManager.ACTION_APPWIDGET_DELETED))
158161

159-
onBackPressedCallback = object : OnBackPressedCallback(false) {
162+
onBackPressedCallback = object : OnBackPressedCallback(hasUnsavedChanges) {
160163
override fun handleOnBackPressed() {
161-
if (hasUnsavedChanges) {
164+
if (isEnabled) {
162165
showDiscardChangesDialog()
163166
}
164167
}
@@ -291,17 +294,16 @@ class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnac
291294
deckAdapter.addDeck(deck)
292295
updateViewVisibility()
293296
updateFabVisibility()
294-
hasUnsavedChanges = true
295297
setUnsavedChanges(true)
296298

297299
// Save the selected deck immediately
298300
saveSelectedDecksToPreferencesCardAnalysisWidget()
299-
hasUnsavedChanges = false
300301
setUnsavedChanges(false)
301302

302-
val selectedDeckId = cardAnalysisWidgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId)
303+
// Update the widget with the new selected deck ID
304+
cardAnalysisWidgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId)
303305
val appWidgetManager = AppWidgetManager.getInstance(this)
304-
CardAnalysisWidget.updateWidget(this, appWidgetManager, appWidgetId, selectedDeckId)
306+
CardAnalysisWidget.updateWidget(this, appWidgetManager, appWidgetId)
305307

306308
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
307309
setResult(RESULT_OK, resultValue)
@@ -328,8 +330,7 @@ class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnac
328330
val updateIntent = Intent(this, CardAnalysisWidget::class.java).apply {
329331
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
330332
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
331-
332-
putExtra("card_analysis_widget_selected_deck_ids", selectedDeck)
333+
putExtra(EXTRA_SELECTED_DECK_IDS, selectedDeck)
333334
}
334335

335336
sendBroadcast(updateIntent)
@@ -347,7 +348,7 @@ class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnac
347348
return
348349
}
349350

350-
context?.let { cardAnalysisWidgetPreferences.deleteDeckData(appWidgetId) }
351+
cardAnalysisWidgetPreferences.deleteDeckData(appWidgetId)
351352
}
352353
}
353354
}

AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ import androidx.recyclerview.widget.RecyclerView
3434
import com.google.android.material.floatingactionbutton.FloatingActionButton
3535
import com.google.android.material.snackbar.Snackbar
3636
import com.ichi2.anki.AnkiActivity
37-
import com.ichi2.anki.DeckUtils
38-
import com.ichi2.anki.DeckUtils.isCollectionEmpty
3937
import com.ichi2.anki.R
4038
import com.ichi2.anki.dialogs.DeckSelectionDialog
4139
import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener
4240
import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
4341
import com.ichi2.anki.dialogs.DiscardChangesDialog
42+
import com.ichi2.anki.isCollectionEmpty
43+
import com.ichi2.anki.isDefaultDeckEmpty
4444
import com.ichi2.anki.showThemedToast
4545
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
4646
import com.ichi2.anki.snackbar.SnackbarBuilder
@@ -280,10 +280,6 @@ class DeckPickerWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackb
280280
}
281281
}
282282

283-
private suspend fun isDefaultDeckEmpty(): Boolean {
284-
return DeckUtils.isDefaultDeckEmpty()
285-
}
286-
287283
/** Updates the view according to the saved preference for appWidgetId.*/
288284
fun updateViewWithSavedPreferences() {
289285
val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferences(appWidgetId)

AnkiDroid/src/main/res/values/08-widget.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343

4444
<!-- Strings to explain usage in Deck Picker and Card Analysis Widget Configuration screen -->
4545
<string name="select_decks_title" comment="Title for Deck Selection Dialog">Select decks</string>
46-
<string name="select_deck_title" comment="Title for Deck Selection Dialog">Select deck</string>
46+
<string name="select_deck_title" comment="Title for Deck Selection Dialog">Select a deck</string>
4747
<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>
4848
<string name="deck_removed_from_widget" comment="Snackbar when deck is removed from widget">Deck removed</string>
4949
<string name="deck_already_selected_message" comment="Snackbar when user try to select the same deck again">This deck is already selected</string>

0 commit comments

Comments
 (0)