Skip to content

Commit 049673a

Browse files
committed
Created ReviewRemindersDatabase and ReviewReminder
GSoC 2025: Review Reminders - Created `ReviewRemindersDatabase`, which contains methods for reading and writing to a Preferences Datastore instance for storing review reminder data - Created `ReviewReminder`, which defines the review reminder data class
1 parent 3990c89 commit 049673a

File tree

2 files changed

+311
-0
lines changed

2 files changed

+311
-0
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright (c) 2025 Eric Li <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.reviewreminders
18+
19+
import android.content.Context
20+
import kotlinx.serialization.Serializable
21+
22+
/**
23+
* Review reminder data class. Handles the fields of a review reminders and the logic behind creating one.
24+
* Below, a public way of creating [ReviewReminder] is exposed via a companion object so that IDs are not abused.
25+
* Annotated with @ConsistentCopyVisibility to ensure copy() is private too and does not leak the constructor.
26+
* TODO: add remaining fields planned for GSoC 2025.
27+
*/
28+
@Serializable
29+
@ConsistentCopyVisibility
30+
data class ReviewReminder private constructor(
31+
val id: Int,
32+
val type: Int, // ReviewReminderTypes ordinal
33+
val hour: Int,
34+
val minute: Int,
35+
val cardTriggerThreshold: Int,
36+
) {
37+
init {
38+
require(hour in 0..23) { "Hour must be between 0 and 23" }
39+
require(minute in 0..59) { "Minute must be between 0 and 59" }
40+
require(cardTriggerThreshold >= 0) { "Card trigger threshold must be >= 0" }
41+
}
42+
43+
companion object {
44+
/**
45+
* Create a new review reminder. This will allocate a new ID for the reminder.
46+
* @param context The context to use for Datastore database access.
47+
* @param type The type of the reminder.
48+
* @param hour The hour of the reminder (0-23).
49+
* @param minute The minute of the reminder (0-59).
50+
* @param cardTriggerThreshold The card trigger threshold.
51+
* @return A new ReviewReminder object.
52+
*/
53+
suspend fun createReviewReminder(
54+
context: Context,
55+
type: ReviewReminderTypes,
56+
hour: Int,
57+
minute: Int,
58+
cardTriggerThreshold: Int,
59+
) = ReviewReminder(
60+
id = ReviewRemindersDatabase(context).allocateReminderId(),
61+
type = type.ordinal,
62+
hour = hour,
63+
minute = minute,
64+
cardTriggerThreshold = cardTriggerThreshold,
65+
)
66+
}
67+
}
68+
69+
/**
70+
* Types of review reminders.
71+
* GSoC 2025: Two kinds planned: single and persistent.
72+
* Possibly extensible in the future.
73+
*/
74+
enum class ReviewReminderTypes {
75+
SINGLE,
76+
PERSISTENT,
77+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/*
2+
* Copyright (c) 2025 Eric Li <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.reviewreminders
18+
19+
import android.content.Context
20+
import androidx.datastore.core.DataStore
21+
import androidx.datastore.preferences.core.Preferences
22+
import androidx.datastore.preferences.core.edit
23+
import androidx.datastore.preferences.core.intPreferencesKey
24+
import androidx.datastore.preferences.core.stringPreferencesKey
25+
import androidx.datastore.preferences.preferencesDataStore
26+
import kotlinx.coroutines.flow.firstOrNull
27+
import kotlinx.serialization.json.Json
28+
29+
/**
30+
* Preferences Datastore for storing review reminders.
31+
*/
32+
internal val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "review_reminders")
33+
34+
/**
35+
* Manages the storage and retrieval of review reminders in the local Preferences Datastore database.
36+
*/
37+
class ReviewRemindersDatabase(
38+
val context: Context,
39+
) {
40+
companion object {
41+
/**
42+
* IDs start at this value and climb upwards by one each time.
43+
*/
44+
private const val FIRST_REMINDER_ID = 0
45+
46+
/**
47+
* Keys of the Preferences Datastore used to store reminders.
48+
* - deck_<deck_id>: String (JSON of List<ReviewReminder>): JSON string of the list of reminders for a deck
49+
* - app_wide: String (JSON of List<ReviewReminder>): JSON string of the list of app-wide reminders
50+
* - next_free_id: Int: The next available free ID for a reminder
51+
* - previously_used_ids: String (JSON of List<Int>): JSON string of the list of previously used but now available IDs
52+
*/
53+
private const val DATASTORE_DECK_SPECIFIC_KEY = "deck_%s"
54+
private const val DATASTORE_APP_WIDE_KEY = "app_wide"
55+
private const val DATASTORE_NEXT_FREE_ID_KEY = "next_free_id"
56+
private const val DATASTORE_PREVIOUSLY_USED_IDS_KEY = "previously_used_ids"
57+
}
58+
59+
/**
60+
* Retrieve the list of reminders for a specific deck.
61+
* @param did The deck ID.
62+
* @return The list of reminders.
63+
*/
64+
suspend fun getRemindersForDeck(did: Long): List<ReviewReminder> {
65+
val key = stringPreferencesKey(String.format(DATASTORE_DECK_SPECIFIC_KEY, did))
66+
return getRemindersForKey(key)
67+
}
68+
69+
/**
70+
* Retrieve the list of app-wide reminders.
71+
* @return The list of app-wide reminders.
72+
*/
73+
suspend fun getAppWideReminders(): List<ReviewReminder> {
74+
val key = stringPreferencesKey(DATASTORE_APP_WIDE_KEY)
75+
return getRemindersForKey(key)
76+
}
77+
78+
/**
79+
* Retrieve the list of reminders given a key to use for the Preferences Datastore.
80+
* @param key The Preferences Datastore key.
81+
* @return The list of reminders associated with this key.
82+
*/
83+
private suspend fun getRemindersForKey(key: Preferences.Key<String>): List<ReviewReminder> {
84+
val jsonString =
85+
context.dataStore.data.firstOrNull()?.let {
86+
it[key]
87+
} ?: ""
88+
if (jsonString.isEmpty()) return emptyList()
89+
val reminders = Json.decodeFromString<List<ReviewReminder>>(jsonString)
90+
return reminders
91+
}
92+
93+
/**
94+
* Set the list of reminders for a specific deck.
95+
* @param did The deck ID.
96+
* @param reminders The reminders to associate with this deck.
97+
*/
98+
suspend fun setRemindersForDeck(
99+
did: Long,
100+
reminders: List<ReviewReminder>,
101+
) {
102+
val key = stringPreferencesKey(String.format(DATASTORE_DECK_SPECIFIC_KEY, did))
103+
return setRemindersForKey(key, reminders)
104+
}
105+
106+
/**
107+
* Set the list of app-wide reminders.
108+
* @param reminders The reminders to associate with the app.
109+
*/
110+
suspend fun setAppWideReminders(reminders: List<ReviewReminder>) {
111+
val key = stringPreferencesKey(DATASTORE_APP_WIDE_KEY)
112+
return setRemindersForKey(key, reminders)
113+
}
114+
115+
/**
116+
* Set the list of reminders given a key to use for the Preferences Datastore.
117+
* @param key The Preferences Datastore key.
118+
* @param reminders The reminders to associate with this key.
119+
*/
120+
private suspend fun setRemindersForKey(
121+
key: Preferences.Key<String>,
122+
reminders: List<ReviewReminder>,
123+
) {
124+
val jsonString = Json.encodeToString(reminders)
125+
context.dataStore.edit { preferences ->
126+
preferences[key] = jsonString
127+
}
128+
}
129+
130+
/**
131+
* Allocate and return the next free reminder ID which can be associated with a new review reminder.
132+
* @return The next free reminder ID.
133+
*/
134+
internal suspend fun allocateReminderId(): Int {
135+
// Get next free ID and previously used IDs
136+
val nextFreeIdKey = intPreferencesKey(DATASTORE_NEXT_FREE_ID_KEY)
137+
val previouslyUsedIdsKey = stringPreferencesKey(DATASTORE_PREVIOUSLY_USED_IDS_KEY)
138+
val (nextFreeId, previouslyUsedIds) =
139+
context.dataStore.data.firstOrNull()?.let { preferences ->
140+
val nextFreeId = preferences[nextFreeIdKey] ?: FIRST_REMINDER_ID
141+
val previouslyUsedIds = preferences[previouslyUsedIdsKey] ?: "[]"
142+
Pair(nextFreeId, previouslyUsedIds)
143+
} ?: Pair(FIRST_REMINDER_ID, "[]")
144+
val previouslyUsedIdsList = Json.decodeFromString<MutableList<Int>>(previouslyUsedIds)
145+
146+
// Use previously used IDs if available, else increment next free ID
147+
if (previouslyUsedIdsList.isNotEmpty()) {
148+
val id = previouslyUsedIdsList.removeAt(previouslyUsedIdsList.lastIndex)
149+
context.dataStore.edit { preferences ->
150+
preferences[previouslyUsedIdsKey] = Json.encodeToString(previouslyUsedIdsList)
151+
}
152+
return id
153+
} else {
154+
context.dataStore.edit { preferences ->
155+
preferences[nextFreeIdKey] = nextFreeId + 1
156+
}
157+
return nextFreeId
158+
}
159+
}
160+
161+
/**
162+
* Deallocate a reminder ID, marking it as available for future use.
163+
* @param id The ID to deallocate.
164+
*/
165+
internal suspend fun deallocateReminderId(id: Int) {
166+
// Get previously used IDs
167+
val previouslyUsedIdsKey = stringPreferencesKey(DATASTORE_PREVIOUSLY_USED_IDS_KEY)
168+
val previouslyUsedIds =
169+
context.dataStore.data.firstOrNull()?.let { preferences ->
170+
preferences[previouslyUsedIdsKey] ?: "[]"
171+
} ?: "[]"
172+
val previouslyUsedIdsList = Json.decodeFromString<MutableList<Int>>(previouslyUsedIds)
173+
174+
// Place the ID into the previously used IDs list so it can be reused in the future
175+
previouslyUsedIdsList.add(id)
176+
context.dataStore.edit { preferences ->
177+
preferences[previouslyUsedIdsKey] = Json.encodeToString(previouslyUsedIdsList)
178+
}
179+
}
180+
181+
/**
182+
* Add a review reminder for a specific deck.
183+
* @param did The deck ID.
184+
* @param newReminder The new review reminder to add.
185+
*/
186+
suspend fun addReviewReminderForDeck(
187+
did: Long,
188+
newReminder: ReviewReminder,
189+
) {
190+
val reminders = getRemindersForDeck(did).toMutableList()
191+
reminders.add(newReminder)
192+
setRemindersForDeck(did, reminders)
193+
}
194+
195+
/**
196+
* Edit a review reminder for a specific deck. Must provide the deck ID and the reminder ID.
197+
* @param did The deck ID.
198+
* @param reminderId The ID of the reminder to edit.
199+
* @param updatedReminder The updated review reminder.
200+
*/
201+
suspend fun editReviewReminderForDeck(
202+
did: Long,
203+
reminderId: Int,
204+
updatedReminder: ReviewReminder,
205+
) {
206+
val reminders = getRemindersForDeck(did).toMutableList()
207+
val indexToEdit = reminders.indexOfFirst { it.id == reminderId }
208+
if (indexToEdit == -1) {
209+
throw IllegalArgumentException("Edit Reminder: Reminder with ID $reminderId not found in deck $did")
210+
}
211+
reminders[indexToEdit] = updatedReminder
212+
setRemindersForDeck(did, reminders)
213+
}
214+
215+
/**
216+
* Delete a review reminder for a specific deck.
217+
* This will deallocate the ID of the reminder and delete it from the list of reminders for the deck.
218+
* @param did The deck ID.
219+
* @param reminderId The ID of the reminder to delete.
220+
*/
221+
suspend fun deleteReviewReminderForDeck(
222+
did: Long,
223+
reminderId: Int,
224+
) {
225+
val reminders = getRemindersForDeck(did).toMutableList()
226+
val reminderToRemove = reminders.find { it.id == reminderId }
227+
if (reminderToRemove == null) {
228+
throw IllegalArgumentException("Delete Reminder: Reminder with ID $reminderId not found in deck $did")
229+
}
230+
reminders.remove(reminderToRemove)
231+
setRemindersForDeck(did, reminders)
232+
deallocateReminderId(reminderId)
233+
}
234+
}

0 commit comments

Comments
 (0)