152 lines
6.9 KiB
Kotlin
152 lines
6.9 KiB
Kotlin
package space.bloggin.vellum
|
|
|
|
import android.content.ContentValues
|
|
import android.content.Context
|
|
import android.database.sqlite.SQLiteDatabase
|
|
import android.database.sqlite.SQLiteOpenHelper
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import java.time.LocalDate
|
|
|
|
data class Note(val ref: Ref, val text: String, val updated: Long)
|
|
data class Mark(val ref: Ref, val created: Long)
|
|
|
|
/** Bookmarks, highlights, notes and reading-plan progress. Everything stays on the phone. */
|
|
class UserData private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "user.db", null, 1) {
|
|
|
|
/** bumps whenever anything changes, so the UI can recompose */
|
|
val rev = mutableIntStateOf(0)
|
|
|
|
private val highlights = HashMap<Int, Int>()
|
|
private val notes = HashMap<Int, Note>()
|
|
private val bookmarks = HashMap<Int, Long>()
|
|
|
|
override fun onCreate(db: SQLiteDatabase) {
|
|
db.execSQL("CREATE TABLE highlight(k INTEGER PRIMARY KEY, color INTEGER, created INTEGER)")
|
|
db.execSQL("CREATE TABLE note(k INTEGER PRIMARY KEY, text TEXT, updated INTEGER)")
|
|
db.execSQL("CREATE TABLE bookmark(k INTEGER PRIMARY KEY, created INTEGER)")
|
|
db.execSQL("CREATE TABLE plan(id TEXT PRIMARY KEY, started TEXT)")
|
|
db.execSQL("CREATE TABLE plan_day(plan TEXT, day INTEGER, done TEXT, PRIMARY KEY(plan,day))")
|
|
db.execSQL("CREATE TABLE activity(date TEXT PRIMARY KEY)")
|
|
}
|
|
|
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}
|
|
|
|
init {
|
|
val db = readableDatabase
|
|
db.rawQuery("SELECT k,color FROM highlight", null).use { while (it.moveToNext()) highlights[it.getInt(0)] = it.getInt(1) }
|
|
db.rawQuery("SELECT k,text,updated FROM note", null).use { while (it.moveToNext()) notes[it.getInt(0)] = Note(Ref.fromKey(it.getInt(0)), it.getString(1), it.getLong(2)) }
|
|
db.rawQuery("SELECT k,created FROM bookmark", null).use { while (it.moveToNext()) bookmarks[it.getInt(0)] = it.getLong(1) }
|
|
}
|
|
|
|
private fun bump() { rev.intValue++ }
|
|
|
|
// highlights: color index 1..6, 0 = none
|
|
fun highlight(r: Ref): Int = highlights[r.key()] ?: 0
|
|
fun setHighlight(refs: Collection<Ref>, color: Int) {
|
|
val db = writableDatabase
|
|
db.beginTransaction()
|
|
try {
|
|
for (r in refs) {
|
|
if (color == 0) { db.delete("highlight", "k=?", arrayOf(r.key().toString())); highlights.remove(r.key()) }
|
|
else {
|
|
db.insertWithOnConflict("highlight", null, ContentValues().apply {
|
|
put("k", r.key()); put("color", color); put("created", System.currentTimeMillis())
|
|
}, SQLiteDatabase.CONFLICT_REPLACE)
|
|
highlights[r.key()] = color
|
|
}
|
|
}
|
|
db.setTransactionSuccessful()
|
|
} finally { db.endTransaction() }
|
|
bump()
|
|
}
|
|
fun allHighlights(): List<Pair<Ref, Int>> = highlights.entries.sortedBy { it.key }.map { Ref.fromKey(it.key) to it.value }
|
|
|
|
fun note(r: Ref): Note? = notes[r.key()]
|
|
fun setNote(r: Ref, text: String) {
|
|
val db = writableDatabase
|
|
if (text.isBlank()) { db.delete("note", "k=?", arrayOf(r.key().toString())); notes.remove(r.key()) }
|
|
else {
|
|
val now = System.currentTimeMillis()
|
|
db.insertWithOnConflict("note", null, ContentValues().apply { put("k", r.key()); put("text", text); put("updated", now) }, SQLiteDatabase.CONFLICT_REPLACE)
|
|
notes[r.key()] = Note(r, text, now)
|
|
}
|
|
bump()
|
|
}
|
|
fun allNotes(): List<Note> = notes.values.sortedByDescending { it.updated }
|
|
|
|
fun isBookmarked(r: Ref) = bookmarks.containsKey(r.key())
|
|
fun toggleBookmark(r: Ref) {
|
|
val db = writableDatabase
|
|
if (isBookmarked(r)) { db.delete("bookmark", "k=?", arrayOf(r.key().toString())); bookmarks.remove(r.key()) }
|
|
else {
|
|
val now = System.currentTimeMillis()
|
|
db.insertWithOnConflict("bookmark", null, ContentValues().apply { put("k", r.key()); put("created", now) }, SQLiteDatabase.CONFLICT_REPLACE)
|
|
bookmarks[r.key()] = now
|
|
}
|
|
bump()
|
|
}
|
|
fun allBookmarks(): List<Mark> = bookmarks.entries.sortedByDescending { it.value }.map { Mark(Ref.fromKey(it.key), it.value) }
|
|
|
|
// ---------------- reading plans ----------------
|
|
fun planStarted(id: String): LocalDate? =
|
|
readableDatabase.rawQuery("SELECT started FROM plan WHERE id=?", arrayOf(id)).use { if (it.moveToFirst()) LocalDate.parse(it.getString(0)) else null }
|
|
|
|
fun startPlan(id: String) {
|
|
val db = writableDatabase
|
|
db.delete("plan_day", "plan=?", arrayOf(id))
|
|
db.insertWithOnConflict("plan", null, ContentValues().apply { put("id", id); put("started", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_REPLACE)
|
|
bump()
|
|
}
|
|
|
|
fun stopPlan(id: String) {
|
|
writableDatabase.delete("plan", "id=?", arrayOf(id))
|
|
writableDatabase.delete("plan_day", "plan=?", arrayOf(id))
|
|
bump()
|
|
}
|
|
|
|
fun daysDone(id: String): Set<Int> =
|
|
readableDatabase.rawQuery("SELECT day FROM plan_day WHERE plan=?", arrayOf(id)).use { c ->
|
|
buildSet { while (c.moveToNext()) add(c.getInt(0)) }
|
|
}
|
|
|
|
fun setDayDone(id: String, day: Int, done: Boolean) {
|
|
val db = writableDatabase
|
|
if (done) {
|
|
db.insertWithOnConflict("plan_day", null, ContentValues().apply { put("plan", id); put("day", day); put("done", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_REPLACE)
|
|
db.insertWithOnConflict("activity", null, ContentValues().apply { put("date", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_IGNORE)
|
|
} else db.delete("plan_day", "plan=? AND day=?", arrayOf(id, day.toString()))
|
|
bump()
|
|
}
|
|
|
|
/** Consecutive days (ending today, or yesterday if today isn't done yet) with a finished plan reading. */
|
|
fun streak(): Int {
|
|
val dates = readableDatabase.rawQuery("SELECT date FROM activity", null).use { c ->
|
|
buildSet { while (c.moveToNext()) add(c.getString(0)) }
|
|
}
|
|
var d = LocalDate.now()
|
|
if (!dates.contains(d.toString())) d = d.minusDays(1)
|
|
var n = 0
|
|
while (dates.contains(d.toString())) { n++; d = d.minusDays(1) }
|
|
return n
|
|
}
|
|
|
|
fun bestStreak(): Int {
|
|
val dates = readableDatabase.rawQuery("SELECT date FROM activity ORDER BY date", null).use { c ->
|
|
buildList { while (c.moveToNext()) add(LocalDate.parse(c.getString(0))) }
|
|
}
|
|
var best = 0; var cur = 0; var prev: LocalDate? = null
|
|
for (d in dates) {
|
|
cur = if (prev != null && prev.plusDays(1) == d) cur + 1 else 1
|
|
best = maxOf(best, cur); prev = d
|
|
}
|
|
return best
|
|
}
|
|
|
|
companion object {
|
|
@Volatile private var instance: UserData? = null
|
|
fun get(ctx: Context): UserData = instance ?: synchronized(this) {
|
|
instance ?: UserData(ctx.applicationContext).also { instance = it }
|
|
}
|
|
}
|
|
}
|