Skip to content

Commit

Permalink
Implemention of Deck Picker Widget !Add Deck Picker Widget to display…
Browse files Browse the repository at this point in the history
… 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).
- Updates every minute using a recurring alarm.
- Retrieves selected decks from shared preferences.
- Handles widget updates and deletions appropriately.

This widget provides users with a quick overview of their decks without needing to open the app.
  • Loading branch information
xenonnn4w committed Jul 30, 2024
1 parent 73dde8a commit 2e71273
Show file tree
Hide file tree
Showing 16 changed files with 1,123 additions and 0 deletions.
21 changes: 21 additions & 0 deletions AnkiDroid/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,27 @@
/>
</receiver>

<receiver
android:name="com.ichi2.widget.DeckPickerWidget"
android:exported="false"
android:enabled="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>

<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>
Expand Down
256 changes: 256 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/widget/DeckPickerWidget.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/*
* Copyright (c) 2024 Anoop <[email protected]>
*
* 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.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import android.widget.RemoteViews
import com.ichi2.anki.AnkiDroidApp.Companion.applicationScope
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds

/**
* 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: Long,
val name: String,
val reviewCount: Int,
val learnCount: Int,
val newCount: Int
)

/**
* AppWidgetProvider for the Deck Picker Widget.
* 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.
* No user actions can be performed from this widget as of now; it is for display purposes only.
*/
class DeckPickerWidget : AppWidgetProvider() {

companion object {
const val ACTION_APPWIDGET_UPDATE = AppWidgetManager.ACTION_APPWIDGET_UPDATE
const val ACTION_UPDATE_WIDGET = "com.ichi2.widget.ACTION_UPDATE_WIDGET"

/**
* Updates the widget with the deck data.
*
* @param context the context of the application
* @param appWidgetManager the app widget manager
* @param widgetId the array of widget IDs
* @param deckIds the array of deck IDs
*/
fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
widgetId: IntArray,
deckIds: LongArray
) {
val remoteViews = RemoteViews(context.packageName, R.layout.widget_deck_picker_large)

applicationScope.launch(Dispatchers.IO) {
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())

remoteViews.addView(R.id.deckCollection, deckView)
}
appWidgetManager.updateAppWidget(widgetId, remoteViews)
}
}

/**
* Retrieves the selected deck IDs from shared preferences for the widget.
*
* @param context the context of the application
* @param appWidgetId the ID of the widget
* @return the array of selected deck IDs
*/
private fun getSelectedDeckIdsFromPreferences(context: Context, appWidgetId: Int): LongArray {
val sharedPreferences = context.getSharedPreferences("DeckPickerWidgetPrefs", Context.MODE_PRIVATE)
val selectedDecksString = sharedPreferences.getString("deck_picker_widget_selected_decks_$appWidgetId", "")
return if (!selectedDecksString.isNullOrEmpty()) {
selectedDecksString.split(",").map { it.toLong() }.toLongArray()
} else {
longArrayOf()
}
}

/**
* Sets a recurring alarm to update the widget every minute.
*
* @param context the context of the application
* @param appWidgetId the ID of the widget
*/
private fun setRecurringAlarm(context: Context, appWidgetId: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, DeckPickerWidget::class.java).apply {
action = ACTION_UPDATE_WIDGET
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val pendingIntent = PendingIntent.getBroadcast(context, appWidgetId, intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE)

if (pendingIntent == null) {
Timber.d("PendingIntent is null, creating a new one for widget ID: $appWidgetId")
val newPendingIntent = PendingIntent.getBroadcast(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)

// Set alarm to trigger every minute
val ONE_MINUTE_MILLIS = 60.seconds.inWholeMilliseconds
alarmManager.setRepeating(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + ONE_MINUTE_MILLIS,
ONE_MINUTE_MILLIS,
newPendingIntent
)
} else {
Timber.d("PendingIntent already exists for widget ID: $appWidgetId")
}
}

/**
* Cancels the recurring alarm for the widget.
*
* @param context the context of the application
* @param appWidgetId the ID of the widget
*/
private fun cancelRecurringAlarm(context: Context, appWidgetId: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, DeckPickerWidget::class.java).apply {
action = ACTION_UPDATE_WIDGET
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val pendingIntent = PendingIntent.getBroadcast(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
Timber.d("Canceling recurring alarm for widget ID: $appWidgetId")
alarmManager.cancel(pendingIntent)
}
}

override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
Timber.d("onUpdate")

for (widgetId in appWidgetIds) {
val selectedDeckIds = getSelectedDeckIdsFromPreferences(context, widgetId)
if (selectedDeckIds.isNotEmpty()) {
updateWidget(context, appWidgetManager, intArrayOf(widgetId), selectedDeckIds)
}
setRecurringAlarm(context, widgetId)
}
}

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)

when (intent.action) {
ACTION_APPWIDGET_UPDATE -> {
val appWidgetManager = AppWidgetManager.getInstance(context)
val appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)
val selectedDeckIds = intent.getLongArrayExtra("deck_picker_widget_selected_deck_ids")

if (appWidgetIds != null && selectedDeckIds != null) {
updateWidget(context, appWidgetManager, appWidgetIds, selectedDeckIds)
}
}
ACTION_UPDATE_WIDGET -> {
val appWidgetManager = AppWidgetManager.getInstance(context)
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
val selectedDeckIds = getSelectedDeckIdsFromPreferences(context, appWidgetId)
if (selectedDeckIds.isNotEmpty()) {
updateWidget(context, appWidgetManager, intArrayOf(appWidgetId), selectedDeckIds)
}
}
}
}
}

/**
* Triggers the cancel recurring alarm when the widget is deleted.
*
* @param context the context of the application
* @param appWidgetIds the array of widget IDs being deleted
*/
override fun onDeleted(context: Context?, appWidgetIds: IntArray?) {
super.onDeleted(context, appWidgetIds)
appWidgetIds?.forEach { widgetId ->
cancelRecurringAlarm(context!!, widgetId)
}
}
}

/**
* This function takes a list of deck IDs and returns a list of data objects containing
* the deck name and statistics (review, learn, and new counts) for each specified deck.
* It fetches the entire deck tree, filters it to include only the specified deck IDs,
* and maps the relevant data to the output list, ensuring the order matches the input list.
* Currently used in DeckPickerWidget and CardAnalysisExtraWidget.
*
* @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<Long>): 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] }
}
Loading

0 comments on commit 2e71273

Please sign in to comment.