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

Implemention of Deck Picker Widget !Add Deck Picker Widget to display deck statistics

This commit introduces the Deck Picker Widget, which displays a list of decks along with the number of cards that are new, in learning, and due for review. It is a display-only widget.

Features:
- Displays deck names and statistics (new, learning, and review counts).
- Retrieves selected decks from shared preferences.
- Can be reconfigured by holding the widget.

This widget provides users with a quick overview of their decks without needing to open the app.
This commit is contained in:
anoop 2024-05-22 13:23:47 +05:30 committed by Arthur Milchior
parent f9ea4df68e
commit 44143f00c4
17 changed files with 1603 additions and 1 deletions

View File

@ -520,6 +520,33 @@
/>
</receiver>
<!-- A widget that displays a few decks's 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.DeckPickerWidget"
android:label="@string/deck_picker_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_deck_picker" />
</receiver>
<!-- Configuration view for the DeckPickerWidget above.
It is opened when adding a new deck picker widget and
by configuration button which appears when the widget is hold or resized.-->
<activity
android:name="com.ichi2.widget.DeckPickerWidgetConfig"
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

@ -0,0 +1,285 @@
/*
* 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
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<DeckId>): List<DeckPickerWidgetData> {
val result = mutableListOf<DeckPickerWidgetData>()
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] }
}

View File

@ -0,0 +1,489 @@
/*
* 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
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<RecyclerView>(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<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) {
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<FloatingActionButton>(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<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 {
saveSelectedDecksToPreferencesDeckPickerWidget()
hasUnsavedChanges = false
setUnsavedChanges(false)
val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(appWidgetId)
val appWidgetManager = AppWidgetManager.getInstance(this)
DeckPickerWidget.updateWidget(this, appWidgetManager, appWidgetId, selectedDeckIds)
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, resultValue)
sendBroadcast(Intent(this, DeckPickerWidget::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 {
val defaultDeckEmpty = isDefaultDeckEmpty()
val totalSelectableDecks = getTotalSelectableDecks()
// Adjust totalSelectableDecks if the default deck is empty
var adjustedTotalSelectableDecks = totalSelectableDecks
if (defaultDeckEmpty) {
adjustedTotalSelectableDecks -= 1
}
val selectedDeckCount = deckAdapter.itemCount
val fab = findViewById<FloatingActionButton>(R.id.fabWidgetDeckPicker)
fab.isVisible = !(selectedDeckCount >= MAX_DECKS_ALLOWED || selectedDeckCount >= adjustedTotalSelectableDecks)
}
}
/**
* Returns the total number of selectable decks.
*
* The operation involves accessing the collection, which might be a time-consuming
* I/O-bound task. Hence, we switch to the IO dispatcher
* to avoid blocking the main thread and ensure a smooth user experience.
*/
private suspend fun getTotalSelectableDecks(): Int {
return withContext(Dispatchers.IO) {
SelectableDeck.fromCollection(includeFiltered = false).size
}
}
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
}
/** Updates the view according to the saved preference for appWidgetId.*/
fun updateViewWithSavedPreferences() {
val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(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
}
val isDeckAlreadySelected = deckAdapter.deckIds.contains(deck.deckId)
if (isDeckAlreadySelected) {
// TODO: Eventually, ensure that the user can't select a deck that is already selected.
showSnackbar(getString(R.string.deck_already_selected_message))
return
}
// Check if the deck is being added to a fully occupied selection
if (deckAdapter.itemCount >= MAX_DECKS_ALLOWED) {
// Snackbar will only be shown when adding the 5th deck
if (deckAdapter.itemCount == MAX_DECKS_ALLOWED) {
showSnackbar(resources.getQuantityString(R.plurals.deck_limit_reached, MAX_DECKS_ALLOWED, MAX_DECKS_ALLOWED))
}
// The FAB visibility should be handled in updateFabVisibility()
} else {
// Add the deck and update views
deckAdapter.addDeck(deck)
updateViewVisibility()
updateFabVisibility()
setupDoneButton()
hasUnsavedChanges = true
setUnsavedChanges(true)
// Show snackbar if the deck is the 5th deck
if (deckAdapter.itemCount == MAX_DECKS_ALLOWED) {
showSnackbar(resources.getQuantityString(R.plurals.deck_limit_reached, MAX_DECKS_ALLOWED, MAX_DECKS_ALLOWED))
}
}
}
/** 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)
if (deckAdapter.itemCount > 0) {
noDecksPlaceholder.visibility = View.GONE
widgetConfigContainer.visibility = View.VISIBLE
} else {
noDecksPlaceholder.visibility = View.VISIBLE
widgetConfigContainer.visibility = View.GONE
}
}
/** ItemTouchHelper callback for handling drag and drop of decks. */
private val itemTouchHelperCallback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0
) {
override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
val selectedDeckCount = deckAdapter.itemCount
return if (selectedDeckCount > 1) {
super.getDragDirs(recyclerView, viewHolder)
} else {
0 // Disable drag if there's only one item
}
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPosition = viewHolder.bindingAdapterPosition
val toPosition = target.bindingAdapterPosition
deckAdapter.moveDeck(fromPosition, toPosition)
hasUnsavedChanges = true
setUnsavedChanges(true)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// No swipe action
}
}
/**
* Saves the selected deck IDs to SharedPreferences and triggers a widget update.
*
* This function retrieves the selected decks from the `deckAdapter`, converts their IDs
* to a comma-separated string, and stores it in SharedPreferences.
* It then sends a broadcast to update the widget with the new deck selection.
*/
fun saveSelectedDecksToPreferencesDeckPickerWidget() {
val selectedDecks = deckAdapter.deckIds.map { it }
deckPickerWidgetPreferences.saveSelectedDecks(appWidgetId, selectedDecks.map { it.toString() })
val updateIntent = Intent(this, DeckPickerWidget::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
putExtra("deck_picker_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 { deckPickerWidgetPreferences.deleteDeckPickerWidgetData(appWidgetId) }
}
}
/**
* Returns whether the deck picker displays any deck.
* Technically, it means that there is a non default deck, or that the default deck is non-empty.
*
* This function is specifically implemented to address an issue where the default deck
* isn't handled correctly when a second deck is added to the
* collection. In this case, the deck tree may incorrectly appear as non-empty when it contains
* only the default deck and no other cards.
*
*/
private suspend fun isCollectionEmpty(): Boolean {
val tree = withCol { sched.deckDueTree() }
if (tree.children.size == 1 && tree.children[0].did == 1L) {
return isDefaultDeckEmpty()
}
return false
}
}
/**
* Unregisters a broadcast receiver from the context silently.
*
* This extension function attempts to unregister a broadcast receiver from the context
* without throwing an exception if the receiver is not registered.
* It catches the `IllegalArgumentException` that is thrown when attempting to unregister
* a receiver that is not registered, allowing the operation to fail gracefully without crashing.
*
* @param receiver The broadcast receiver to be unregistered.
*
* @see ContextWrapper.unregisterReceiver
* @see IllegalArgumentException
*/
fun ContextWrapper.unregisterReceiverSilently(receiver: BroadcastReceiver) {
try {
unregisterReceiver(receiver)
} catch (e: IllegalArgumentException) {
Timber.d(e, "unregisterReceiverSilently")
}
}

View File

@ -0,0 +1,100 @@
/*
* 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
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.R
import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Adapter class for displaying and managing a list of selectable decks in a RecyclerView.
*
* @property decks the list of selectable decks to display
* @property onDeleteDeck a function to call when a deck is removed
*/
class WidgetConfigScreenAdapter(
private val onDeleteDeck: (SelectableDeck, Int) -> Unit
) : RecyclerView.Adapter<WidgetConfigScreenAdapter.DeckViewHolder>() {
private val decks: MutableList<SelectableDeck> = mutableListOf()
private val coroutineScope = CoroutineScope(Dispatchers.Main)
// Property to get the list of deck IDs
val deckIds: List<Long> get() = decks.map { it.deckId }
class DeckViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val deckNameTextView: TextView = itemView.findViewById(R.id.deck_name)
val removeButton: ImageButton = itemView.findViewById(R.id.action_button_remove_deck)
}
/** Creates and inflates the view for each item in the RecyclerView
* @param parent the parent ViewGroup
* @param viewType the type of the view
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeckViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.widget_item_deck_config, parent, false)
return DeckViewHolder(view)
}
override fun onBindViewHolder(holder: DeckViewHolder, position: Int) {
val deck = decks[position]
coroutineScope.launch {
val deckName = withContext(Dispatchers.IO) {
withCol { decks.get(deck.deckId)!!.name }
}
holder.deckNameTextView.text = deckName
}
holder.removeButton.setOnClickListener {
onDeleteDeck(deck, position)
}
}
override fun getItemCount(): Int = decks.size
fun addDeck(deck: SelectableDeck) {
decks.add(deck)
notifyItemInserted(decks.size - 1)
}
fun removeDeck(deckId: Long) {
// Find the position of the deck with the given ID
val position = decks.indexOfFirst { it.deckId == deckId }
if (position != -1) {
decks.removeAt(position)
notifyItemRemoved(position)
}
}
fun moveDeck(fromPosition: Int, toPosition: Int) {
val deck = decks.removeAt(fromPosition)
decks.add(toPosition, deck)
notifyItemMoved(fromPosition, toPosition)
}
}

View File

@ -0,0 +1,76 @@
/*
* 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
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) {
/**
* Prefix for the SharedPreferences key used to store the selected decks for the DeckPickerWidget.
* 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 deckPickerSharedPreferences = context.getSharedPreferences("DeckPickerWidgetPrefs", Context.MODE_PRIVATE)
/**
* Deletes the selected deck IDs from the shared preferences for the given widget ID.
*/
fun deleteDeckPickerWidgetData(appWidgetId: Int) {
deckPickerSharedPreferences.edit {
remove(getDeckPickerWidgetKey(appWidgetId))
}
}
/**
* 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 {
val selectedDecksString = deckPickerSharedPreferences.getString(getDeckPickerWidgetKey(appWidgetId), "")
return if (!selectedDecksString.isNullOrEmpty()) {
selectedDecksString.split(",").map { it.toLong() }.toLongArray()
} else {
longArrayOf()
}
}
/**
* Saves the selected deck IDs to the shared preferences for the given widget ID.
*/
fun saveSelectedDecks(appWidgetId: Int, selectedDecks: List<String>) {
deckPickerSharedPreferences.edit {
putString(getDeckPickerWidgetKey(appWidgetId), selectedDecks.joinToString(","))
}
}
}
/**
* Generates the key for the shared preferences for the given widget ID.
*/
private fun getDeckPickerWidgetKey(appWidgetId: Int): String {
return "deck_picker_widget_selected_decks_$appWidgetId"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,79 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible">
<LinearLayout
android:id="@+id/no_decks_placeholder"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
android:visibility="visible"
android:background="?attr/colorSurface">
<com.ichi2.ui.FixedTextView
style="@style/TextAppearance.AppCompat.Medium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="7dp"
android:gravity="center"
android:padding="30dp"
android:text="@string/no_selected_deck_placeholder_title" />
</LinearLayout>
<LinearLayout
android:id="@+id/widgetConfigContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical"
android:visibility="gone">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewSelectedDecks"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
<RelativeLayout
android:id="@+id/buttonBackgroundLayer"
android:layout_width="match_parent"
android:layout_height="45dp"
android:layout_alignParentBottom="true"
>
<!-- Save / Cancel -->
<Button
android:id="@+id/submit_button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:textSize="@dimen/abc_text_size_button_material"
style="@style/Widget.Material3.Button.TextButton"
android:singleLine="true"
android:padding="3dp"
android:layout_gravity="center"
android:text="@string/save" />
</RelativeLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabWidgetDeckPicker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:layout_margin="16dp"
android:layout_marginBottom="40dp"
android:src="@drawable/ic_add_white" />
</RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This file is used for preview purposes only and
is not actually utilized in the widget's implementation. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?attr/colorPrimaryContainer"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_weight="3"
android:height="48dp"
android:paddingStart="15dp"
android:paddingTop="10dp"
android:text="@string/deck1Name_deck_picker_widget"
android:textColor="?android:attr/textColorPrimary"
android:textSize="22sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="50"
android:textColor="@color/flag_reviewer_blue"
android:textSize="20sp"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="52"
android:textColor="@color/material_red_A700"
android:textSize="20sp"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="50"
android:textColor="@color/flag_reviewer_green"
android:textSize="20sp"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_weight="3"
android:height="48dp"
android:paddingBottom="1dp"
android:paddingStart="15dp"
android:paddingTop="10dp"
android:text="@string/deck2Name_deck_picker_widget"
android:textColor="?android:attr/textColorPrimary"
android:textSize="22sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="30"
android:textColor="@color/flag_reviewer_blue"
android:textSize="20sp"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="20"
android:textColor="@color/material_red_A700"
android:textSize="20sp"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="10"
android:textColor="@color/flag_reviewer_green"
android:textSize="20sp"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_weight="3"
android:height="48dp"
android:paddingBottom="4dp"
android:paddingStart="15dp"
android:paddingTop="10dp"
android:text="@string/deck3Name_deck_picker_widget"
android:textColor="?android:attr/textColorPrimary"
android:textSize="22sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="25"
android:textColor="@color/flag_reviewer_blue"
android:textSize="20sp"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="15"
android:textColor="@color/material_red_A700"
android:textSize="20sp"
tools:ignore="HardcodedText" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:text="58"
android:textColor="@color/flag_reviewer_green"
android:textSize="20sp"
tools:ignore="HardcodedText" />
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,20 @@
<RelativeLayout
android:id="@+id/widget_deck_picker"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clickable="true"
android:focusable="false"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<LinearLayout
android:id="@+id/deckCollection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:background="?attr/colorSurface"
android:orientation="vertical">
</LinearLayout>
</RelativeLayout>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:padding="10dp"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<TextView
android:id="@+id/deck_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="10dp"
tools:text="Deck Name"
android:textColor="?android:attr/textColorPrimary"
android:textSize="18sp" />
<ImageButton
android:id="@+id/action_button_remove_deck"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:paddingEnd="10dp"
android:src="@drawable/ic_delete_white" />
</LinearLayout>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<TextView
android:id="@+id/deckName"
android:layout_width="0dp"
android:layout_height="43dp"
android:layout_weight="3"
android:height="48dp"
android:paddingBottom="2dp"
android:paddingEnd="12dp"
android:paddingStart="15dp"
android:paddingTop="10dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="22sp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
tools:text="Deck1" />
<TextView
android:id="@+id/deckNew"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:textColor="@color/flag_reviewer_blue"
android:textSize="20sp"
android:text=""
tools:text="50" />
<TextView
android:id="@+id/deckLearn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:textColor="@color/material_red_A700"
android:textSize="20sp"
android:text=""
tools:text="50" />
<TextView
android:id="@+id/deckDue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingEnd="5dp"
android:textColor="@color/flag_reviewer_green"
android:textSize="20sp"
android:text=""
tools:text="50" />
</LinearLayout>

View File

@ -38,4 +38,16 @@
</plurals>
<string name="widget_add_note_button">Add new AnkiDroid note</string>
<string name="deck_picker_widget_description">Deck Picker Widget</string>
<!-- Strings to explain usage in Deck Picker 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>
<string name="deck_already_selected_message" comment="Snackbar when user try to select the same deck again">This deck is already selected</string>
<plurals name="deck_limit_reached">
<item quantity="one">You can select up to %d deck.</item>
<item quantity="other">You can select up to %d decks.</item>
</plurals>
</resources>

View File

@ -291,6 +291,11 @@
<item>@string/pref_cat_plugins</item>
</string-array>
<!-- Sample deck names and for Deck Picker Widget layout in widget picker screen
These strings are marked as `translatable=false` because translating them would defeat the purpose of the example, which relies on specific language usage.-->
<string name="deck1Name_deck_picker_widget" translatable="false" comment="Example of a Deck name. Android's widget provider displays it as an example of what the Deck Picker widget may look like">English</string>
<string name="deck2Name_deck_picker_widget" translatable="false" comment="Example of a Deck name. Android's widget provider displays it as an example of what the Deck Picker widget may look like">Español</string>
<string name="deck3Name_deck_picker_widget" translatable="false" comment="Example of a Deck name. Android's widget provider displays it as an example of what the Deck Picker widget may look like">日本語</string>
<string name="answer_buttons_position_preference" translatable="false">answerButtonPosition</string>
<string-array name="answer_buttons_position" translatable="false">

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 follow.
The default is 3 cells in width and 2 in height
The height is between 1 and 5 cells.
The width is between 3 and 4 cells.
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 50 and 315
* width 203 and 276-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/widget_deck_picker_large"
android:initialLayout="@layout/widget_deck_picker_large"
android:configure="com.ichi2.widget.DeckPickerWidgetConfig"
android:widgetFeatures="reconfigurable"
android:minWidth="203dp"
android:minHeight="50dp"
android:minResizeWidth="203dp"
android:minResizeHeight="50dp"
android:maxResizeWidth="276dp"
android:maxResizeHeight="315dp"
android:previewImage="@drawable/widget_deck_picker_drawable"
android:previewLayout="@layout/widget_deck_picker_drawable_v31"
android:resizeMode="horizontal|vertical"
android:targetCellHeight="2"
android:targetCellWidth="4"
android:widgetCategory="home_screen" />

View File

@ -0,0 +1,76 @@
/*
* 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
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.RobolectricTest
import com.ichi2.widget.getDeckNameAndStats
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
class DeckNameAndStatsTest : RobolectricTest() {
@Test
fun testGetDeckNameAndStats_withTopLevelDecks() = runTest {
val deck1Id = addDeck("Deck 1")
val deck2Id = addDeck("Deck 2")
val deckIds = listOf(deck1Id, deck2Id)
val result = getDeckNameAndStats(deckIds)
assertEquals(2, result.size)
assertEquals("Deck 1", result[0].name)
assertEquals(deck1Id, result[0].deckId)
assertEquals("Deck 2", result[1].name)
assertEquals(deck2Id, result[1].deckId)
}
@Test
fun testGetDeckNameAndStats_ordering() = runTest {
val deckAId = addDeck("Deck A")
val deckBId = addDeck("Deck B")
val deckCId = addDeck("Deck C")
val deckIds = listOf(deckCId, deckAId, deckBId)
val result = getDeckNameAndStats(deckIds)
assertEquals(3, result.size)
assertEquals("Deck C", result[0].name)
assertEquals(deckCId, result[0].deckId)
assertEquals("Deck A", result[1].name)
assertEquals(deckAId, result[1].deckId)
assertEquals("Deck B", result[2].name)
assertEquals(deckBId, result[2].deckId)
}
@Test
fun testGetDeckNameAndStats_withChildDecks() = runTest {
val deck1Id = addDeck("Deck 1")
val child1Id = addDeck("Deck 1::Child 1")
val deckIds = listOf(deck1Id, child1Id)
val result = getDeckNameAndStats(deckIds)
assertEquals(2, result.size)
assertEquals("Deck 1", result[0].name)
assertEquals(deck1Id, result[0].deckId)
assertEquals("Child 1", result[1].name) // Changed to truncated name
assertEquals(child1Id, result[1].deckId)
}
}

View File

@ -0,0 +1,158 @@
/*
* 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
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.DeckPickerWidgetConfig
import com.ichi2.widget.WidgetPreferences
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
@RunWith(AndroidJUnit4::class)
class DeckPickerWidgetConfigTest : RobolectricTest() {
private lateinit var activity: DeckPickerWidgetConfig
private lateinit var lifecycle: LifecycleRegistry
private lateinit var widgetPreferences: WidgetPreferences
/**
* 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.
*/
@Before
override fun setUp() {
super.setUp()
val intent = Intent(ApplicationProvider.getApplicationContext(), DeckPickerWidgetConfig::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 1)
}
activity = Robolectric.buildActivity(DeckPickerWidgetConfig::class.java, intent)
.create()
.start()
.resume()
.get()
lifecycle = LifecycleRegistry(activity)
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
widgetPreferences = WidgetPreferences(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.saveSelectedDecksToPreferencesDeckPickerWidget()
// Verify saved decks
val selectedDeckIds = widgetPreferences.getSelectedDeckIdsFromPreferencesDeckPickerWidget(1)
assert(selectedDeckIds.contains(deck1.deckId))
}
/**
* 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.saveSelectedDecks(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
assert(adapter != null && adapter.itemCount == 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()
assert(noDecksPlaceholder.visibility == View.VISIBLE)
assert(widgetConfigContainer.visibility == View.GONE)
// Add a deck and update view visibility
val deck = DeckSelectionDialog.SelectableDeck(1, "Deck 1")
activity.deckAdapter.addDeck(deck)
activity.updateViewVisibility()
assert(noDecksPlaceholder.visibility == View.GONE)
assert(widgetConfigContainer.visibility == 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)
assert(recyclerView.adapter?.itemCount == 1)
}
}

View File

@ -48,6 +48,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.DeckPickerWidgetConfig
import org.robolectric.Robolectric
import org.robolectric.android.controller.ActivityController
import java.util.function.Function
@ -90,7 +91,8 @@ object ActivityList {
get(SingleFragmentActivity::class.java),
get(CardViewerActivity::class.java),
get(InstantNoteEditorActivity::class.java),
get(MultimediaActivity::class.java)
get(MultimediaActivity::class.java),
get(DeckPickerWidgetConfig::class.java)
)
}