Add app/src/main/java/space/bloggin/vellum

This commit is contained in:
2026-10-19 01:20:27 +00:00
parent 29fb869421
commit de6fa34983
3 changed files with 446 additions and 0 deletions
@@ -0,0 +1,182 @@
package space.bloggin.vellum
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
val LocalBackdrop = compositionLocalOf { Backdrops.all[0] }
/** UI text (menus, buttons) uses the system sans; reading text uses the chosen serif. */
val UiFont = FontFamily.SansSerif
@Composable
fun BackdropFill(bd: Backdrop, modifier: Modifier = Modifier) {
if (bd.image != null) {
Image(painterResource(bd.image), null, modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else {
Box(modifier.fillMaxSize().background(Brush.linearGradient(bd.gradient!!)))
}
}
@Composable
fun Panel(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
val bd = LocalBackdrop.current
Column(
modifier
.background(bd.panel, RectangleShape)
.border(BorderStroke(1.dp, bd.faint.copy(alpha = 0.45f)), RectangleShape),
content = content
)
}
/** Square button, the house style. */
@Composable
fun SqButton(
text: String,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
filled: Boolean = false,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val bd = LocalBackdrop.current
val fg = if (filled) (if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC)) else bd.ink
val bg = if (filled) (if (bd.dark) bd.ink else bd.ink) else Color.Transparent
Row(
modifier
.clip(RectangleShape)
.background(bg)
.border(1.dp, bd.ink.copy(alpha = if (enabled) 0.8f else 0.3f), RectangleShape)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
if (icon != null) {
Icon(icon, null, tint = fg.copy(alpha = if (enabled) 1f else 0.4f), modifier = Modifier.size(18.dp))
if (text.isNotEmpty()) Spacer(Modifier.width(6.dp))
}
if (text.isNotEmpty()) Text(text, color = fg.copy(alpha = if (enabled) 1f else 0.4f), fontFamily = UiFont, fontSize = 14.sp, maxLines = 1)
}
}
/** Icon with a small label underneath, used in the selection toolbar. */
@Composable
fun ToolButton(text: String, icon: ImageVector, active: Boolean = false, onClick: () -> Unit) {
val bd = LocalBackdrop.current
Column(
Modifier
.width(68.dp)
.background(if (active) bd.ink.copy(alpha = 0.12f) else Color.Transparent)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(icon, text, tint = bd.ink, modifier = Modifier.size(22.dp))
Spacer(Modifier.height(3.dp))
Text(text, color = bd.ink, fontSize = 11.sp, fontFamily = UiFont, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
}
}
@Composable
fun Chip(text: String, selected: Boolean, modifier: Modifier = Modifier, family: FontFamily = UiFont, onClick: () -> Unit) {
val bd = LocalBackdrop.current
Box(
modifier
.background(if (selected) bd.ink else Color.Transparent, RectangleShape)
.border(1.dp, bd.ink.copy(alpha = 0.7f), RectangleShape)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(text, color = if (selected) (if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC)) else bd.ink, fontFamily = family, fontSize = 14.sp, maxLines = 1)
}
}
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
val bd = LocalBackdrop.current
Text(text.uppercase(), modifier.padding(top = 18.dp, bottom = 8.dp), color = bd.rubric, fontFamily = Fonts.display, fontSize = 15.sp, letterSpacing = 1.5.sp)
}
@Composable
fun ToggleRow(label: String, checked: Boolean, sub: String? = null, onChange: (Boolean) -> Unit) {
val bd = LocalBackdrop.current
Row(
Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(label, color = bd.ink, fontFamily = UiFont, fontSize = 16.sp)
if (sub != null) Text(sub, color = bd.faint, fontFamily = UiFont, fontSize = 13.sp)
}
Switch(
checked = checked, onCheckedChange = onChange,
colors = SwitchDefaults.colors(
checkedThumbColor = if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC),
checkedTrackColor = bd.ink,
uncheckedThumbColor = bd.ink,
uncheckedTrackColor = Color.Transparent,
uncheckedBorderColor = bd.ink,
checkedBorderColor = bd.ink,
),
thumbContent = null
)
}
}
@Composable
fun SliderRow(label: String, value: Float, range: ClosedFloatingPointRange<Float>, display: String, steps: Int = 0, onChange: (Float) -> Unit) {
val bd = LocalBackdrop.current
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row {
Text(label, color = bd.ink, fontFamily = UiFont, fontSize = 16.sp, modifier = Modifier.weight(1f))
Text(display, color = bd.faint, fontFamily = UiFont, fontSize = 14.sp)
}
Slider(
value = value, onValueChange = onChange, valueRange = range, steps = steps,
colors = SliderDefaults.colors(thumbColor = bd.ink, activeTrackColor = bd.ink, inactiveTrackColor = bd.ink.copy(alpha = 0.25f),
activeTickColor = Color.Transparent, inactiveTickColor = Color.Transparent)
)
}
}
@Composable
fun Rule(modifier: Modifier = Modifier, alpha: Float = 0.35f, thickness: Dp = 1.dp) {
val bd = LocalBackdrop.current
Box(modifier.fillMaxWidth().height(thickness).background(bd.faint.copy(alpha = alpha)))
}
fun inkOn(bd: Backdrop) = if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC)
val RoundSmall = RoundedCornerShape(0.dp)
fun uiStyle(bd: Backdrop, size: Int = 15) = TextStyle(color = bd.ink, fontFamily = UiFont, fontSize = size.sp)
@@ -0,0 +1,151 @@
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 }
}
}
}
@@ -0,0 +1,113 @@
package space.bloggin.vellum
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import java.util.Calendar
object Today {
data class V(val ref: Ref, val label: String, val text: String)
fun get(ctx: Context): V {
val bible = Bible.get(ctx)
val s = Settings.get(ctx)
val r = bible.parseRef(Votd.refFor()) ?: Ref(43, 3, 16)
val ref = if (r.verse == 0) r.copy(verse = 1) else r
return V(ref, bible.refLabel(ref) + " " + bible.trCode(s.tr), bible.plainRange(s.tr, ref))
}
fun openIntent(ctx: Context, ref: Ref, code: Int): PendingIntent {
val i = Intent(ctx, MainActivity::class.java).apply {
action = "space.bloggin.vellum.OPEN_${ref.key()}"
putExtra("ref", ref.key())
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return PendingIntent.getActivity(ctx, code, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
}
class VotdWidget : AppWidgetProvider() {
override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
update(ctx, mgr, ids)
}
companion object {
fun update(ctx: Context, mgr: AppWidgetManager = AppWidgetManager.getInstance(ctx),
ids: IntArray = mgr.getAppWidgetIds(ComponentName(ctx, VotdWidget::class.java))) {
if (ids.isEmpty()) return
val v = Today.get(ctx)
for (id in ids) {
val rv = RemoteViews(ctx.packageName, R.layout.widget_votd)
rv.setTextViewText(R.id.w_text, "“" + v.text + "”")
rv.setTextViewText(R.id.w_ref, v.label)
rv.setOnClickPendingIntent(R.id.w_root, Today.openIntent(ctx, v.ref, 1))
mgr.updateAppWidget(id, rv)
}
}
fun refreshAll(ctx: Context) = try { update(ctx) } catch (_: Exception) {}
}
}
object Daily {
private const val CHANNEL = "votd"
fun schedule(ctx: Context) {
val s = Settings.get(ctx)
val am = ctx.getSystemService(AlarmManager::class.java)
val pi = PendingIntent.getBroadcast(ctx, 7, Intent(ctx, DailyReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
am.cancel(pi)
val cal = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, if (s.notify) s.notifyHour else 0)
set(Calendar.MINUTE, if (s.notify) s.notifyMinute else 5)
set(Calendar.SECOND, 0)
if (timeInMillis <= System.currentTimeMillis()) add(Calendar.DAY_OF_YEAR, 1)
}
// daily tick: refreshes the widget and (if turned on) posts the verse notification
am.setInexactRepeating(AlarmManager.RTC_WAKEUP, cal.timeInMillis, AlarmManager.INTERVAL_DAY, pi)
}
fun notifyNow(ctx: Context) {
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
val nm = ctx.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Verse of the day", NotificationManager.IMPORTANCE_DEFAULT))
val v = Today.get(ctx)
val n = NotificationCompat.Builder(ctx, CHANNEL)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(v.label)
.setContentText(v.text)
.setStyle(NotificationCompat.BigTextStyle().bigText("“" + v.text + "”"))
.setContentIntent(Today.openIntent(ctx, v.ref, 2))
.setAutoCancel(true)
.build()
nm.notify(1, n)
}
}
class DailyReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
VotdWidget.refreshAll(ctx)
if (Settings.get(ctx).notify) Daily.notifyNow(ctx)
}
}
class BootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
Daily.schedule(ctx)
VotdWidget.refreshAll(ctx)
}
}