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

This commit is contained in:
2026-10-19 01:20:24 +00:00
parent bfe95765fd
commit 55b964aada
5 changed files with 1260 additions and 0 deletions
@@ -0,0 +1,240 @@
package space.bloggin.vellum
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.util.LruCache
import java.io.File
data class Book(val id: Int, val name: String, val abbr: String, val chapters: Int)
data class Translation(val id: Int, val code: String, val name: String)
data class Ref(val book: Int, val chapter: Int, val verse: Int, val end: Int = 0) {
fun key() = book * 1_000_000 + chapter * 1000 + verse
companion object {
fun fromKey(k: Int) = Ref(k / 1_000_000, (k / 1000) % 1000, k % 1000)
}
}
data class StrongsEntry(val id: String, val lemma: String, val xlit: String, val pron: String, val deriv: String, val def: String, val kjv: String)
data class XRef(val target: Ref, val votes: Int)
data class SearchHit(val ref: Ref, val text: String)
/** Item kinds in the continuous scroll list. */
object Item {
const val VOTD = 0
const val BOOK = 1
const val CHAPTER = 2
const val VERSE = 3
fun enc(kind: Int, b: Int, c: Int, v: Int) = (kind shl 24) or (b shl 16) or (c shl 8) or v
fun kind(x: Int) = x ushr 24
fun book(x: Int) = (x shr 16) and 0xff
fun chapter(x: Int) = (x shr 8) and 0xff
fun verse(x: Int) = x and 0xff
}
class Bible private constructor(private val db: SQLiteDatabase) {
val books: List<Book>
val translations: List<Translation>
/** verse counts per (book, chapter) using KJV versification */
private val verseCounts = HashMap<Int, Int>()
/** flat list of items for the scrolling reader */
val items: IntArray
/** index in [items] of each chapter header, key = book*1000+chapter */
private val chapterIndex = HashMap<Int, Int>()
private val bookIndex = HashMap<Int, Int>()
/** every verse in order, for reel mode and reading plans */
val verseItems: IntArray
private val chapterVerseStart = HashMap<Int, Int>()
private val titles = HashMap<Int, String>()
init {
books = db.rawQuery("SELECT id,name,abbr,chapters FROM books ORDER BY id", null).use { c ->
buildList { while (c.moveToNext()) add(Book(c.getInt(0), c.getString(1), c.getString(2), c.getInt(3))) }
}
translations = db.rawQuery("SELECT id,code,name FROM translations ORDER BY id", null).use { c ->
buildList { while (c.moveToNext()) add(Translation(c.getInt(0), c.getString(1), c.getString(2))) }
}
db.rawQuery("SELECT book,chapter,verses FROM chapters ORDER BY book,chapter", null).use { c ->
while (c.moveToNext()) verseCounts[c.getInt(0) * 1000 + c.getInt(1)] = c.getInt(2)
}
db.rawQuery("SELECT book,chapter,text FROM titles", null).use { c ->
while (c.moveToNext()) titles[c.getInt(0) * 1000 + c.getInt(1)] = c.getString(2)
}
val list = ArrayList<Int>(33000)
val vlist = ArrayList<Int>(31200)
list.add(Item.enc(Item.VOTD, 0, 0, 0))
for (b in books) {
bookIndex[b.id] = list.size
list.add(Item.enc(Item.BOOK, b.id, 0, 0))
for (c in 1..b.chapters) {
chapterIndex[b.id * 1000 + c] = list.size
chapterVerseStart[b.id * 1000 + c] = vlist.size
list.add(Item.enc(Item.CHAPTER, b.id, c, 0))
val n = verseCounts[b.id * 1000 + c] ?: 0
for (v in 1..n) {
list.add(Item.enc(Item.VERSE, b.id, c, v))
vlist.add(Item.enc(Item.VERSE, b.id, c, v))
}
}
}
items = list.toIntArray()
verseItems = vlist.toIntArray()
}
fun book(id: Int) = books[id - 1]
fun verseCount(b: Int, c: Int) = verseCounts[b * 1000 + c] ?: 0
fun title(b: Int, c: Int) = titles[b * 1000 + c]
fun trCode(tr: Int) = translations.getOrNull(tr)?.code ?: "KJV"
fun indexOfBook(b: Int) = bookIndex[b] ?: 0
fun indexOfChapter(b: Int, c: Int) = chapterIndex[b * 1000 + c] ?: 0
fun indexOfVerse(b: Int, c: Int, v: Int) = indexOfChapter(b, c) + v.coerceIn(0, verseCount(b, c))
fun pageOfVerse(b: Int, c: Int, v: Int) = (chapterVerseStart[b * 1000 + c] ?: 0) + (v - 1).coerceAtLeast(0)
fun nextChapter(b: Int, c: Int): Pair<Int, Int>? = when {
c < book(b).chapters -> b to c + 1
b < 66 -> b + 1 to 1
else -> null
}
fun prevChapter(b: Int, c: Int): Pair<Int, Int>? = when {
c > 1 -> b to c - 1
b > 1 -> b - 1 to book(b - 1).chapters
else -> null
}
/** all chapters in canonical order, encoded book*1000+chapter */
val allChapters: IntArray by lazy {
books.flatMap { b -> (1..b.chapters).map { b.id * 1000 + it } }.toIntArray()
}
// ---------------- text ----------------
private val chapterCache = object : LruCache<Long, Array<String?>>(160) {}
/** Verse markup for a chapter; index 0 = verse 1. */
fun chapter(tr: Int, b: Int, c: Int): Array<String?> {
val key = (tr.toLong() shl 32) or (b * 1000L + c)
chapterCache.get(key)?.let { return it }
val n = verseCount(b, c)
val arr = arrayOfNulls<String>(maxOf(n, 1))
db.rawQuery("SELECT verse, coalesce(text, plain) FROM verses WHERE tr=? AND book=? AND chapter=?",
arrayOf(tr.toString(), b.toString(), c.toString())).use { cur ->
while (cur.moveToNext()) {
val v = cur.getInt(0)
if (v in 1..arr.size) arr[v - 1] = cur.getString(1)
}
}
chapterCache.put(key, arr)
return arr
}
fun verse(tr: Int, b: Int, c: Int, v: Int): String? = chapter(tr, b, c).getOrNull(v - 1)
fun plainVerse(tr: Int, b: Int, c: Int, v: Int): String = Markup.plain(verse(tr, b, c, v) ?: "")
fun plainRange(tr: Int, r: Ref): String {
val end = if (r.end > r.verse) r.end else r.verse
return (r.verse..end).joinToString(" ") { plainVerse(tr, r.book, r.chapter, it) }.trim()
}
fun refLabel(r: Ref, abbr: Boolean = false): String {
val name = if (abbr) book(r.book).abbr else if (r.book == 19 && r.chapter > 0) "Psalm" else book(r.book).name
return when {
r.verse == 0 -> "$name ${r.chapter}"
r.end > r.verse -> "$name ${r.chapter}:${r.verse}-${r.end}"
else -> "$name ${r.chapter}:${r.verse}"
}
}
// ---------------- lookups ----------------
fun search(tr: Int, query: String, scope: Int, limit: Int = 500): List<SearchHit> {
val q = query.trim()
if (q.length < 2) return emptyList()
val words = q.split(Regex("\\s+")).filter { it.isNotBlank() }
val phrase = q.startsWith("\"") && q.endsWith("\"") && q.length > 2
val conds = ArrayList<String>()
val args = ArrayList<String>()
args.add(tr.toString())
if (phrase) {
conds.add("plain LIKE ?"); args.add("%" + q.trim('"') + "%")
} else for (w in words) {
conds.add("plain LIKE ?"); args.add("%$w%")
}
when (scope) {
1 -> conds.add("book <= 39")
2 -> conds.add("book >= 40")
}
val sql = "SELECT book,chapter,verse,plain FROM verses WHERE tr=? AND " + conds.joinToString(" AND ") +
" ORDER BY book,chapter,verse LIMIT $limit"
return db.rawQuery(sql, args.toTypedArray()).use { c ->
buildList { while (c.moveToNext()) add(SearchHit(Ref(c.getInt(0), c.getInt(1), c.getInt(2)), c.getString(3))) }
}
}
fun xrefs(b: Int, c: Int, v: Int): List<XRef> =
db.rawQuery("SELECT tb,tc,tv,te,votes FROM xref WHERE book=? AND chapter=? AND verse=? ORDER BY votes DESC",
arrayOf(b.toString(), c.toString(), v.toString())).use { cur ->
buildList { while (cur.moveToNext()) add(XRef(Ref(cur.getInt(0), cur.getInt(1), cur.getInt(2), cur.getInt(3)), cur.getInt(4))) }
}
fun strongs(id: String): StrongsEntry? =
db.rawQuery("SELECT id,lemma,xlit,pron,deriv,def,kjv FROM strongs WHERE id=?", arrayOf(id)).use { c ->
if (c.moveToFirst()) StrongsEntry(c.getString(0), c.getString(1) ?: "", c.getString(2) ?: "", c.getString(3) ?: "",
c.getString(4) ?: "", c.getString(5) ?: "", c.getString(6) ?: "") else null
}
/** KJV verses using a Strong's number. */
fun strongsUses(id: String, limit: Int = 200): List<SearchHit> =
db.rawQuery("SELECT book,chapter,verse,plain FROM verses WHERE tr=0 AND text LIKE ? ORDER BY book,chapter,verse LIMIT $limit",
arrayOf("%|$id}%")).use { c ->
buildList { while (c.moveToNext()) add(SearchHit(Ref(c.getInt(0), c.getInt(1), c.getInt(2)), c.getString(3))) }
}
fun strongsCount(id: String): Int =
db.rawQuery("SELECT count(*) FROM verses WHERE tr=0 AND text LIKE ?", arrayOf("%|$id}%")).use { c ->
if (c.moveToFirst()) c.getInt(0) else 0
}
/** Parse references like "John 3:16", "1 cor 13:4-7", "ps 23". */
fun parseRef(s: String): Ref? {
val m = Regex("^\\s*([1-3]?\\s*[a-zA-Z .]+?)\\s*(\\d+)(?:\\s*[:.]\\s*(\\d+)(?:\\s*-\\s*(\\d+))?)?\\s*$").find(s) ?: return null
val name = m.groupValues[1].replace(".", "").replace(Regex("\\s+"), " ").trim().lowercase()
if (name.isEmpty()) return null
val compact = name.replace(" ", "")
val book = books.firstOrNull { it.name.lowercase().replace(" ", "") == compact }
?: books.firstOrNull { it.abbr.lowercase().replace(" ", "") == compact }
?: books.firstOrNull { it.name.lowercase().replace(" ", "").startsWith(compact) }
?: aliases[compact]?.let { book(it) }
?: return null
val ch = m.groupValues[2].toIntOrNull() ?: return null
if (ch !in 1..book.chapters) return null
val v = m.groupValues[3].toIntOrNull() ?: 0
val e = m.groupValues[4].toIntOrNull() ?: 0
return Ref(book.id, ch, v, e)
}
private val aliases = mapOf(
"psalm" to 19, "psa" to 19, "pss" to 19, "jn" to 43, "jhn" to 43, "mt" to 40, "mk" to 41, "mrk" to 41, "lk" to 42,
"rv" to 66, "revelations" to 66, "song" to 22, "songofsongs" to 22, "canticles" to 22, "qoh" to 21, "eccles" to 21,
"phm" to 57, "php" to 50, "jas" to 59, "jud" to 65, "jdg" to 7, "1jn" to 62, "2jn" to 63, "3jn" to 64,
"ezk" to 26, "1kgs" to 11, "2kgs" to 12, "1kin" to 11, "2kin" to 12
)
companion object {
@Volatile private var instance: Bible? = null
private const val DB_VERSION = 1
fun get(ctx: Context): Bible = instance ?: synchronized(this) {
instance ?: open(ctx.applicationContext).also { instance = it }
}
private fun open(ctx: Context): Bible {
val f = File(ctx.noBackupFilesDir, "bible-v$DB_VERSION.db")
if (!f.exists()) {
val tmp = File(f.path + ".tmp")
ctx.assets.open("bible.db").use { input -> tmp.outputStream().use { input.copyTo(it, 1 shl 16) } }
tmp.renameTo(f)
ctx.noBackupFilesDir.listFiles()?.filter { it.name.startsWith("bible-v") && it.name != f.name }?.forEach { it.delete() }
}
val db = SQLiteDatabase.openDatabase(f.path, null, SQLiteDatabase.OPEN_READONLY or SQLiteDatabase.NO_LOCALIZED_COLLATORS)
return Bible(db)
}
}
}
@@ -0,0 +1,103 @@
package space.bloggin.vellum
import java.time.LocalDate
import java.time.temporal.ChronoUnit
data class Topic(val name: String, val blurb: String, val refs: List<String>)
object Topics {
val all = listOf(
Topic("Comfort", "When you're hurting", listOf("Ps 23:1-4", "Matt 11:28-30", "2 Cor 1:3-4", "Ps 34:18", "Isa 41:10", "John 14:27", "Rev 21:4", "Ps 147:3", "Isa 40:1-2", "Lam 3:22-23")),
Topic("Anxiety & worry", "When your mind won't rest", listOf("Phil 4:6-7", "1 Pet 5:7", "Matt 6:25-27", "Matt 6:34", "Ps 55:22", "Ps 94:19", "John 14:1", "Isa 26:3", "Prov 12:25", "Ps 46:1-3")),
Topic("Strength", "When you're worn out", listOf("Isa 40:29-31", "Phil 4:13", "2 Cor 12:9-10", "Ps 73:26", "Neh 8:10", "Eph 6:10", "Ps 28:7", "Hab 3:19", "Ps 18:32", "Isa 12:2")),
Topic("Grief & loss", "When someone is gone", listOf("Matt 5:4", "John 11:25-26", "1 Thess 4:13-14", "Ps 34:18", "Rev 21:4", "Eccl 3:1-4", "John 16:22", "Ps 30:5", "2 Cor 4:17-18", "Rom 8:38-39")),
Topic("Fear", "When you're afraid", listOf("Josh 1:9", "Ps 27:1", "Isa 41:10", "2 Tim 1:7", "Ps 56:3-4", "Deut 31:6", "Ps 118:6", "Isa 43:1-2", "1 John 4:18", "Ps 91:1-2")),
Topic("Courage", "When you have to face it", listOf("Deut 31:6", "Josh 1:9", "1 Cor 16:13", "Ps 31:24", "Prov 28:1", "Ps 138:3", "Acts 4:29", "2 Chr 32:7", "Isa 54:4", "Heb 13:6")),
Topic("Forgiveness", "Giving it and receiving it", listOf("1 John 1:9", "Eph 4:32", "Col 3:13", "Matt 6:14-15", "Ps 103:10-12", "Isa 1:18", "Mic 7:18-19", "Luke 6:37", "Matt 18:21-22", "Acts 3:19")),
Topic("Love", "What it is and what it does", listOf("1 Cor 13:4-7", "John 3:16", "1 John 4:7-8", "Rom 5:8", "John 15:12-13", "1 John 4:19", "Rom 8:38-39", "Song 8:7", "1 Pet 4:8", "Col 3:14")),
Topic("Hope", "When things look bleak", listOf("Jer 29:11", "Rom 15:13", "Rom 8:28", "Lam 3:21-23", "Heb 11:1", "Ps 42:11", "Isa 40:31", "Rom 5:3-5", "Ps 39:7", "Heb 6:19")),
Topic("Peace", "Quiet in the storm", listOf("John 14:27", "Phil 4:7", "Isa 26:3", "Num 6:24-26", "Ps 4:8", "Col 3:15", "John 16:33", "Ps 29:11", "Rom 5:1", "2 Thess 3:16")),
Topic("Patience", "Waiting well", listOf("Ps 27:14", "Ps 37:7", "Rom 12:12", "Gal 6:9", "Jas 1:2-4", "Isa 40:31", "Eccl 7:8", "Lam 3:25-26", "Jas 5:7-8", "Heb 10:36")),
Topic("Anger", "When your temper rises", listOf("Jas 1:19-20", "Eph 4:26-27", "Prov 15:1", "Prov 16:32", "Ps 37:8", "Eccl 7:9", "Prov 29:11", "Col 3:8", "Prov 19:11", "Eph 4:31-32")),
Topic("Temptation", "When it pulls at you", listOf("1 Cor 10:13", "Jas 4:7", "Matt 26:41", "Heb 4:15-16", "Jas 1:12-14", "Ps 119:11", "Gal 5:16", "2 Pet 2:9", "Rom 6:12-14", "Prov 4:14-15")),
Topic("Wisdom & guidance", "When you need direction", listOf("Prov 3:5-6", "Jas 1:5", "Ps 32:8", "Ps 119:105", "Isa 30:21", "Prov 16:9", "Prov 2:6", "Ps 25:4-5", "Prov 9:10", "Col 3:16")),
Topic("Thankfulness", "Counting what you have", listOf("1 Thess 5:16-18", "Ps 100:4-5", "Ps 107:1", "Col 3:17", "Ps 118:24", "Jas 1:17", "Ps 136:1", "Phil 4:4", "Eph 5:20", "Ps 9:1")),
Topic("Healing", "For body and spirit", listOf("Jer 17:14", "Isa 53:5", "Ps 103:2-3", "Jas 5:14-15", "Ps 147:3", "3 John 1:2", "Exod 15:26", "Prov 17:22", "Mal 4:2", "Matt 9:35")),
Topic("Loneliness", "When you feel alone", listOf("Deut 31:8", "Ps 68:6", "Matt 28:20", "Ps 27:10", "Isa 41:10", "Heb 13:5", "Ps 139:7-10", "John 14:18", "Ps 25:16", "Zeph 3:17")),
Topic("Money & contentment", "Enough is enough", listOf("Heb 13:5", "Phil 4:11-12", "1 Tim 6:6-8", "Matt 6:19-21", "Prov 30:8-9", "Eccl 5:10", "Matt 6:24", "Prov 22:7", "Luke 12:15", "Phil 4:19")),
Topic("Work", "Doing it well", listOf("Col 3:23-24", "Prov 14:23", "Prov 16:3", "Eccl 9:10", "2 Thess 3:10", "Prov 22:29", "Gal 6:9", "1 Cor 15:58", "Prov 13:4", "Ps 90:17")),
Topic("Marriage & family", "The people at home", listOf("Gen 2:24", "Eph 5:25", "Col 3:18-21", "Prov 31:10-12", "Eccl 4:9-12", "Josh 24:15", "Prov 22:6", "1 Pet 3:7", "Ruth 1:16", "Ps 127:1")),
Topic("Faith", "Trusting what you can't see", listOf("Heb 11:1", "Heb 11:6", "Rom 10:17", "2 Cor 5:7", "Mark 11:22-24", "Matt 17:20", "Eph 2:8-9", "Gal 2:20", "Jas 2:17", "Rom 1:17")),
Topic("Salvation", "The heart of it", listOf("John 3:16-17", "Rom 10:9-10", "Eph 2:8-9", "Rom 6:23", "Acts 4:12", "John 14:6", "Titus 3:5", "Rom 3:23-24", "1 John 5:11-13", "2 Cor 5:17")),
Topic("Sleep & rest", "For the end of the day", listOf("Ps 4:8", "Ps 127:2", "Prov 3:24", "Matt 11:28", "Ps 3:5", "Ps 91:1", "Exod 33:14", "Ps 121:3-4", "Ps 62:1", "Heb 4:9-10")),
Topic("Praise", "Just because", listOf("Ps 150:1-6", "Ps 103:1-2", "Ps 34:1", "Ps 95:1-2", "Ps 145:3", "Ps 96:1-3", "Rev 4:11", "Ps 63:3-4", "Isa 25:1", "Ps 8:1")),
)
}
object Votd {
private val extra = listOf(
"Gen 1:1", "Gen 1:27", "Gen 28:15", "Gen 50:20", "Exod 14:14", "Num 23:19", "Deut 6:5", "Deut 7:9", "Josh 24:15", "1 Sam 16:7",
"2 Sam 22:31", "1 Chr 16:11", "2 Chr 7:14", "Neh 9:6", "Job 19:25", "Ps 1:1-2", "Ps 16:11", "Ps 19:1", "Ps 19:14", "Ps 27:4",
"Ps 37:4", "Ps 37:5", "Ps 46:10", "Ps 51:10", "Ps 62:5", "Ps 84:11", "Ps 90:12", "Ps 91:11", "Ps 103:8", "Ps 119:105",
"Ps 121:1-2", "Ps 139:14", "Ps 143:8", "Prov 3:9-10", "Prov 4:23", "Prov 11:25", "Prov 15:3", "Prov 18:10", "Prov 27:17", "Eccl 3:11",
"Eccl 12:13", "Isa 6:8", "Isa 9:6", "Isa 40:8", "Isa 43:18-19", "Isa 55:8-9", "Isa 58:11", "Isa 64:8", "Jer 31:3", "Jer 33:3",
"Ezek 36:26", "Dan 2:21", "Hos 6:6", "Joel 2:13", "Amos 5:24", "Mic 6:8", "Nah 1:7", "Zeph 3:17", "Hag 2:9", "Zech 4:6",
"Mal 3:10", "Matt 5:3-10", "Matt 5:14-16", "Matt 5:44", "Matt 6:9-13", "Matt 6:33", "Matt 7:7", "Matt 7:12", "Matt 22:37-39", "Matt 28:19-20",
"Mark 9:23", "Mark 10:27", "Mark 10:45", "Luke 1:37", "Luke 6:31", "Luke 6:38", "Luke 12:34", "Luke 15:7", "John 1:1", "John 1:5",
"John 1:14", "John 8:12", "John 8:32", "John 10:10", "John 10:11", "John 11:35", "John 13:34-35", "John 15:5", "Acts 1:8", "Acts 2:38",
"Acts 20:35", "Rom 1:16", "Rom 8:1", "Rom 8:18", "Rom 8:31", "Rom 10:13", "Rom 12:1-2", "Rom 12:10", "Rom 12:21", "1 Cor 2:9",
"1 Cor 6:19-20", "1 Cor 10:31", "1 Cor 13:13", "1 Cor 15:55-57", "2 Cor 4:16", "2 Cor 9:7", "Gal 5:22-23", "Gal 6:2", "Eph 2:10", "Eph 3:20-21",
"Eph 6:11", "Phil 1:6", "Phil 2:3-4", "Phil 3:13-14", "Phil 4:8", "Col 3:2", "1 Thess 5:11", "2 Tim 3:16-17", "Titus 2:11", "Heb 4:12",
"Heb 12:1-2", "Heb 13:8", "Jas 1:22", "Jas 4:8", "1 Pet 2:9", "1 Pet 3:15", "2 Pet 3:9", "1 John 3:1", "1 John 3:18", "Jude 1:24-25",
"Rev 3:20", "Rev 22:13",
)
val pool: List<String> by lazy { (Topics.all.flatMap { it.refs } + extra).distinct() }
/** Same verse all day, different verse every day, order shuffled per year so it doesn't repeat in a loop. */
fun refFor(date: LocalDate = LocalDate.now()): String {
val list = pool.shuffled(java.util.Random(date.year.toLong() * 7919))
return list[(date.dayOfYear - 1) % list.size]
}
}
data class PlanDay(val day: Int, val chapters: List<Int>)
data class Plan(val id: String, val name: String, val blurb: String, val days: Int, val build: (Bible) -> List<List<Int>>)
object Plans {
private fun split(chs: List<Int>, days: Int): List<List<Int>> =
(0 until days).map { i -> chs.subList(i * chs.size / days, (i + 1) * chs.size / days) }
private fun range(b: Bible, from: Int, to: Int) = b.allChapters.filter { it / 1000 in from..to }
val all = listOf(
Plan("year", "Bible in a Year", "Genesis to Revelation, about 3 chapters a day", 365) { b -> split(b.allChapters.toList(), 365) },
Plan("year2", "Bible in a Year: Old & New together", "Some Old Testament and some New every day", 365) { b ->
val ot = split(range(b, 1, 39), 365); val nt = split(range(b, 40, 66), 365)
ot.indices.map { ot[it] + nt[it] }
},
Plan("gospels", "The Gospels in a Month", "Matthew, Mark, Luke and John in 30 days", 30) { b -> split(range(b, 40, 43), 30) },
Plan("nt90", "New Testament in 90 Days", "About 3 chapters a day", 90) { b -> split(range(b, 40, 66), 90) },
Plan("pp", "Psalms & Proverbs in a Month", "5 psalms and a proverb a day", 31) { b ->
val ps = split(range(b, 19, 19), 31); val pr = range(b, 20, 20)
ps.indices.map { ps[it] + pr[it] }
},
Plan("acts", "Acts & the Letters of Paul in 60 Days", "The early church, about 2 chapters a day", 60) { b -> split(range(b, 44, 58), 60) },
)
/** today's day number (1-based) */
fun today(started: java.time.LocalDate): Int = ChronoUnit.DAYS.between(started, java.time.LocalDate.now()).toInt() + 1
fun label(b: Bible, chs: List<Int>): String {
if (chs.isEmpty()) return ""
val groups = ArrayList<MutableList<Int>>()
for (c in chs) {
val last = groups.lastOrNull()
if (last != null && last.last() / 1000 == c / 1000 && last.last() % 1000 + 1 == c % 1000) last.add(c) else groups.add(mutableListOf(c))
}
return groups.joinToString(", ") { g ->
val book = b.book(g.first() / 1000).name
if (g.size == 1) "$book ${g.first() % 1000}" else "$book ${g.first() % 1000}-${g.last() % 1000}"
}
}
}
@@ -0,0 +1,186 @@
package space.bloggin.vellum
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.core.view.WindowCompat
sealed class Screen {
data object GoTo : Screen()
data object Search : Screen()
data class Library(val tab: Int = 0) : Screen()
data object PlansScreen : Screen()
data object TopicsScreen : Screen()
data class TopicDetail(val topic: Topic) : Screen()
data object Appearance : Screen()
data object SettingsScreen : Screen()
data object About : Screen()
data class ShareImg(val text: String, val label: String) : Screen()
}
sealed class Sheet {
data class StrongsWords(val ref: Ref) : Sheet()
data class StrongsDef(val id: String, val word: String) : Sheet()
data class Xrefs(val ref: Ref) : Sheet()
data class Compare(val refs: List<Ref>) : Sheet()
data class NoteEdit(val ref: Ref) : Sheet()
data object ReadAloud : Sheet()
data object AutoScroll : Sheet()
data object Translations : Sheet()
}
class AppState(val ctx: Context, val bible: Bible, val user: UserData, val settings: Settings, val speech: Speech) {
val screens: SnapshotStateList<Screen> = mutableStateListOf()
var sheet by mutableStateOf<Sheet?>(null)
var sheetStack = mutableStateListOf<Sheet>()
val selection: SnapshotStateList<Ref> = mutableStateListOf()
/** a jump the reader should perform; reader clears it */
var jump by mutableStateOf<Ref?>(null)
var jumpTop by mutableStateOf(false)
/** briefly glows after a jump */
var flash by mutableStateOf<Ref?>(null)
var autoScroll by mutableStateOf(false)
var drawerOpen by mutableStateOf(false)
/** current chapter shown at the top of the reader */
var currentBook by mutableIntStateOf(1)
var currentChapter by mutableIntStateOf(1)
var currentVerse by mutableIntStateOf(1)
init {
val last = settings.lastItem
if (last in 1 until bible.items.size) {
val it = bible.items[last]
currentBook = maxOf(1, Item.book(it)); currentChapter = maxOf(1, Item.chapter(it)); currentVerse = maxOf(1, Item.verse(it))
}
}
fun open(s: Screen) { drawerOpen = false; screens.add(s) }
fun back(): Boolean {
when {
sheet != null -> { sheet = if (sheetStack.isNotEmpty()) sheetStack.removeAt(sheetStack.lastIndex) else null }
drawerOpen -> drawerOpen = false
screens.isNotEmpty() -> screens.removeAt(screens.lastIndex)
selection.isNotEmpty() -> selection.clear()
autoScroll -> autoScroll = false
else -> return false
}
return true
}
fun showSheet(s: Sheet, stack: Boolean = false) {
if (stack && sheet != null) sheetStack.add(sheet!!) else sheetStack.clear()
sheet = s
}
fun closeSheet() { sheetStack.clear(); sheet = null }
fun goTo(r: Ref) {
screens.clear(); sheet = null; sheetStack.clear(); drawerOpen = false
jump = r
if (r.verse > 0) flash = r
}
fun goTop() { screens.clear(); sheet = null; drawerOpen = false; jumpTop = true }
// ------------ selection helpers -------------
fun sortedSelection() = selection.sortedBy { it.key() }
fun selectionLabel(): String = labelFor(sortedSelection())
fun labelFor(refs: List<Ref>): String {
if (refs.isEmpty()) return ""
val first = refs.first()
val sameChapter = refs.all { it.book == first.book && it.chapter == first.chapter }
if (!sameChapter) return refs.joinToString("; ") { bible.refLabel(it) }
val vs = refs.map { it.verse }
val parts = ArrayList<String>()
var start = vs[0]; var prev = vs[0]
for (v in vs.drop(1) + listOf(-1)) {
if (v == prev + 1) { prev = v; continue }
parts.add(if (start == prev) "$start" else "$start-$prev")
start = v; prev = v
}
return bible.book(first.book).name + " " + first.chapter + ":" + parts.joinToString(",")
}
fun textFor(refs: List<Ref>, tr: Int = settings.tr): String =
refs.joinToString(" ") { bible.plainVerse(tr, it.book, it.chapter, it.verse) }.trim()
fun copy(text: String, label: String) {
val cm = ctx.getSystemService(ClipboardManager::class.java)
cm.setPrimaryClip(ClipData.newPlainText(label, "“$text”\n— $label (${bible.trCode(settings.tr)})"))
Toast.makeText(ctx, "Copied $label", Toast.LENGTH_SHORT).show()
}
fun shareText(text: String, label: String) {
val i = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "“$text”\n— $label (${bible.trCode(settings.tr)})")
}
ctx.startActivity(Intent.createChooser(i, "Share verse").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
fun toast(s: String) = Toast.makeText(ctx, s, Toast.LENGTH_SHORT).show()
}
class MainActivity : ComponentActivity() {
lateinit var state: AppState
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val settings = Settings.get(this)
val bible = Bible.get(this)
val user = UserData.get(this)
val speech = Speech(this, bible, settings)
state = AppState(this, bible, user, settings, speech)
handleIntent(intent, first = true)
Daily.schedule(this)
VotdWidget.refreshAll(this)
setContent {
val bd = settings.activeBg
LaunchedEffect(bd.dark) {
enableEdgeToEdge(
statusBarStyle = if (bd.dark) SystemBarStyle.dark(0) else SystemBarStyle.light(0, 0),
navigationBarStyle = if (bd.dark) SystemBarStyle.dark(0) else SystemBarStyle.light(0, 0),
)
}
val scheme = if (bd.dark) darkColorScheme(primary = bd.ink, surface = bd.panel, onSurface = bd.ink, background = bd.panel)
else lightColorScheme(primary = bd.ink, surface = bd.panel, onSurface = bd.ink, background = bd.panel)
MaterialTheme(colorScheme = scheme) {
CompositionLocalProvider(LocalBackdrop provides bd) {
BackHandler(enabled = state.sheet != null || state.drawerOpen || state.screens.isNotEmpty() || state.selection.isNotEmpty() || state.autoScroll) {
state.back()
}
App(state)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent, first = false)
}
private fun handleIntent(i: Intent?, first: Boolean) {
val k = i?.getIntExtra("ref", 0) ?: 0
if (k > 0) state.goTo(Ref.fromKey(k))
else if (first && state.settings.openAtVotd) state.jumpTop = true
}
override fun onDestroy() {
state.speech.shutdown()
super.onDestroy()
}
}
@@ -0,0 +1,71 @@
package space.bloggin.vellum
/**
* Verse markup: {words|H430} Strong's group, [words] translator italics, <words> words of Jesus.
*/
object Markup {
data class Seg(val text: String, val jesus: Boolean, val italic: Boolean, val strong: String?)
fun parse(s: String): List<Seg> {
val out = ArrayList<Seg>()
var jesus = false
var italic = false
val buf = StringBuilder()
fun flush(strong: String? = null) {
if (buf.isNotEmpty()) {
out.add(Seg(buf.toString(), jesus, italic, strong)); buf.clear()
}
}
var i = 0
while (i < s.length) {
when (val ch = s[i]) {
'<' -> { flush(); jesus = true }
'>' -> { flush(); jesus = false }
'[' -> { flush(); italic = true }
']' -> { flush(); italic = false }
'{' -> {
flush()
val bar = s.indexOf('|', i)
val close = s.indexOf('}', i)
if (bar in (i + 1) until close) {
// the word itself may contain [ ] italics in rare cases; strip them
val word = s.substring(i + 1, bar)
val num = s.substring(bar + 1, close)
out.add(Seg(word, jesus, italic, num))
i = close
} else buf.append(ch)
}
else -> buf.append(ch)
}
i++
}
flush()
return out
}
fun plain(s: String): String {
if (s.none { it == '{' || it == '[' || it == '<' }) return s
val sb = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val ch = s[i]
when (ch) {
'<', '>', '[', ']' -> {}
'{' -> {
val bar = s.indexOf('|', i)
val close = s.indexOf('}', i)
if (bar in (i + 1) until close) {
sb.append(s, i + 1, bar); i = close
} else sb.append(ch)
}
else -> sb.append(ch)
}
i++
}
return sb.toString()
}
/** Strong's-tagged words in a verse, in order, deduplicated by number+word. */
fun words(s: String): List<Pair<String, String>> =
parse(s).filter { it.strong != null }.map { it.text to it.strong!! }.distinct()
}
@@ -0,0 +1,660 @@
package space.bloggin.vellum
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.abs
const val MAX_TEXT_WIDTH = 720
@Composable
fun App(st: AppState) {
val bd = LocalBackdrop.current
val speaking by st.speech.speaking
val view = LocalView.current
DisposableEffect(st.autoScroll, speaking) {
view.keepScreenOn = st.settings.keepAwake && (st.autoScroll || speaking)
onDispose { view.keepScreenOn = false }
}
Box(Modifier.fillMaxSize()) {
BackdropFill(bd)
key(st.settings.mode) {
if (st.settings.mode == 0) ScrollReader(st) else ReelReader(st)
}
TopBar(st)
Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().navigationBarsPadding()) {
if (speaking) SpeechBar(st)
if (st.autoScroll) AutoScrollBar(st)
if (st.selection.isNotEmpty()) SelectionBar(st)
}
st.screens.lastOrNull()?.let { ScreenHost(st, it) }
Drawer(st)
st.sheet?.let { SheetHost(st, it) }
}
}
// ------------------------------------------------------------------ top bar
@Composable
fun TopBar(st: AppState) {
val bd = LocalBackdrop.current
val b = st.bible
Row(
Modifier
.fillMaxWidth()
.background(bd.panel.copy(alpha = 1f))
.statusBarsPadding()
.height(52.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconBtn(Icons.Filled.Menu, "Menu") { st.drawerOpen = true }
Row(
Modifier.weight(1f).clickable { st.open(Screen.GoTo) }.padding(horizontal = 6.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val name = b.book(st.currentBook).name
Text(
if (st.currentBook == 19) "Psalm ${st.currentChapter}" else "$name ${st.currentChapter}",
color = bd.ink, fontFamily = Fonts.display, fontSize = 22.sp, maxLines = 1
)
Icon(Icons.Filled.ArrowDropDown, null, tint = bd.ink)
}
Box(
Modifier.border(1.dp, bd.ink.copy(alpha = 0.7f)).clickable { st.showSheet(Sheet.Translations) }.padding(horizontal = 8.dp, vertical = 4.dp)
) { Text(b.trCode(st.settings.tr), color = bd.ink, fontFamily = UiFont, fontSize = 13.sp) }
IconBtn(Icons.Filled.Search, "Search") { st.open(Screen.Search) }
IconBtn(Icons.Filled.TextFields, "Appearance") { st.open(Screen.Appearance) }
IconBtn(Icons.Filled.Headphones, "Read aloud") { st.showSheet(Sheet.ReadAloud) }
}
}
@Composable
fun IconBtn(icon: androidx.compose.ui.graphics.vector.ImageVector, desc: String, tint: Color? = null, size: Int = 24, onClick: () -> Unit) {
val bd = LocalBackdrop.current
Box(Modifier.size(44.dp).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, desc, tint = tint ?: bd.ink, modifier = Modifier.size(size.dp))
}
}
// ------------------------------------------------------------------ verse text
fun buildVerse(
markup: String, v: Int, s: Settings, bd: Backdrop, showNumber: Boolean,
hasNote: Boolean, onStrong: ((String, String) -> Unit)?, onNote: (() -> Unit)?,
): AnnotatedString = buildAnnotatedString {
if (showNumber) {
withStyle(SpanStyle(color = bd.rubric, fontSize = 0.6.em, baselineShift = BaselineShift(0.35f), fontStyle = FontStyle.Normal)) {
append("$v")
}
append(" ")
}
for (seg in Markup.parse(markup)) {
val style = SpanStyle(
color = if (seg.jesus && s.jesusBlue) bd.jesus else Color.Unspecified,
fontStyle = if (seg.italic && s.italics) FontStyle.Italic else null,
)
if (onStrong != null && seg.strong != null) {
val num = seg.strong
withLink(
LinkAnnotation.Clickable(
tag = num,
styles = TextLinkStyles(style.merge(SpanStyle(textDecoration = TextDecoration.Underline))),
linkInteractionListener = { onStrong(num, seg.text) }
)
) { append(seg.text) }
} else withStyle(style) { append(seg.text) }
}
if (hasNote && onNote != null) {
append(" ")
withLink(LinkAnnotation.Clickable("note", TextLinkStyles(SpanStyle(color = bd.rubric)), { onNote() })) { append("✎") }
}
}
@Composable
fun readingStyle(s: Settings, bd: Backdrop, scale: Float = 1f): TextStyle {
val f = Fonts.all[s.font.coerceIn(0, Fonts.all.lastIndex)]
return TextStyle(color = bd.ink, fontFamily = f.family, fontSize = (s.size * scale).sp, lineHeight = (s.size * scale * s.spacing).sp)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun VerseRow(st: AppState, b: Int, c: Int, v: Int) {
val bd = LocalBackdrop.current
val s = st.settings
@Suppress("UNUSED_VARIABLE") val rev = st.user.rev.intValue
val ref = Ref(b, c, v)
val markup = st.bible.verse(s.tr, b, c, v)
val selected = st.selection.any { it.book == b && it.chapter == c && it.verse == v }
val hl = st.user.highlight(ref)
val note = st.user.note(ref)
val bookmarked = st.user.isBookmarked(ref)
val speaking = st.speech.speaking.value && st.speech.current.value == ref
val flash = remember { Animatable(0f) }
LaunchedEffect(st.flash) {
if (st.flash == ref) {
flash.snapTo(0.55f); delay(400); flash.animateTo(0f, tween(1600)); st.flash = null
}
}
val onStrong: ((String, String) -> Unit)? = if (s.wordStudy && s.tr == 0) { id, w -> st.showSheet(Sheet.StrongsDef(id, w)) } else null
val text = remember(markup, s.jesusBlue, s.italics, bd, onStrong != null, note != null) {
buildVerse(markup ?: "", v, s, bd, true, note != null, onStrong) { st.showSheet(Sheet.NoteEdit(ref)) }
}
val style = readingStyle(s, bd)
val rubric = bd.rubric
val wide = s.parallel >= 0 && s.parallel != s.tr
Box(
Modifier
.widthIn(max = if (wide) 1100.dp else MAX_TEXT_WIDTH.dp)
.fillMaxWidth()
.background(HighlightColors[hl])
.background(if (speaking) bd.jesus.copy(alpha = 0.14f) else Color.Transparent)
.background(bd.rubric.copy(alpha = flash.value * 0.25f))
.drawBehind {
if (selected) {
drawRect(rubric.copy(alpha = 0.10f))
drawRect(rubric, size = Size(4.dp.toPx(), size.height))
}
if (bookmarked) {
val w = 7.dp.toPx(); val h = 18.dp.toPx()
drawRect(rubric, topLeft = Offset(size.width - w - 2.dp.toPx(), 0f), size = Size(w, h))
}
}
.combinedClickable(
onClick = {
if (selected) st.selection.removeAll { it == ref } else st.selection.add(ref)
},
onLongClick = {
// long press selects the whole range from the first selected verse
val first = st.selection.firstOrNull { it.book == b && it.chapter == c }
if (first == null) st.selection.add(ref) else {
val lo = minOf(first.verse, v); val hi = maxOf(first.verse, v)
for (x in lo..hi) if (st.selection.none { it.book == b && it.chapter == c && it.verse == x }) st.selection.add(Ref(b, c, x))
}
}
)
.padding(horizontal = 20.dp, vertical = 3.dp)
) {
val par = s.parallel
if (par >= 0 && par != s.tr) {
val other = st.bible.verse(par, b, c, v)
val otherText = remember(other, s.jesusBlue, bd) { buildVerse(other ?: "—", v, s, bd, false, false, null, null) }
Row {
Text(text, Modifier.weight(1f), style = style.copy(fontSize = style.fontSize * 0.9f, lineHeight = style.lineHeight * 0.9f))
Spacer(Modifier.width(14.dp))
Box(Modifier.width(1.dp).heightIn(min = 20.dp).background(bd.faint.copy(alpha = 0.3f)))
Spacer(Modifier.width(14.dp))
Text(otherText, Modifier.weight(1f), style = style.copy(fontSize = style.fontSize * 0.9f, lineHeight = style.lineHeight * 0.9f))
}
} else {
Text(text, style = style)
}
}
}
@Composable
fun ChapterHeader(st: AppState, b: Int, c: Int) {
val bd = LocalBackdrop.current
val s = st.settings
Column(
Modifier.widthIn(max = MAX_TEXT_WIDTH.dp).fillMaxWidth().padding(top = 26.dp, bottom = 10.dp, start = 20.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
if (b == 19) "PSALM" else if (st.bible.book(b).chapters == 1) st.bible.book(b).name.uppercase() else "CHAPTER",
color = bd.faint, fontFamily = Fonts.display, fontSize = 13.sp, letterSpacing = 3.sp
)
Text("$c", color = bd.rubric, fontFamily = Fonts.fell, fontSize = (s.size * 2.4f).sp, lineHeight = (s.size * 2.6f).sp)
val par = s.parallel
if (par >= 0 && par != s.tr) {
Row(Modifier.fillMaxWidth().padding(top = 4.dp)) {
Text(st.bible.trCode(s.tr), Modifier.weight(1f), color = bd.faint, fontFamily = UiFont, fontSize = 12.sp, textAlign = TextAlign.Center)
Text(st.bible.trCode(par), Modifier.weight(1f), color = bd.faint, fontFamily = UiFont, fontSize = 12.sp, textAlign = TextAlign.Center)
}
}
st.bible.title(b, c)?.let { t ->
if (s.tr == 0 || s.tr == 1) {
Text(t, Modifier.padding(top = 8.dp), color = bd.faint, fontFamily = Fonts.all[s.font].family, fontStyle = FontStyle.Italic,
fontSize = (s.size * 0.85f).sp, textAlign = TextAlign.Center, lineHeight = (s.size * 1.2f).sp)
}
}
}
}
@Composable
fun BookHeader(st: AppState, b: Int) {
val bd = LocalBackdrop.current
val book = st.bible.book(b)
Column(
Modifier.widthIn(max = MAX_TEXT_WIDTH.dp).fillMaxWidth().padding(top = 56.dp, bottom = 6.dp, start = 20.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (b == 1 || b == 40) {
Text(if (b == 1) "THE OLD TESTAMENT" else "THE NEW TESTAMENT", color = bd.faint, fontFamily = Fonts.display, fontSize = 14.sp, letterSpacing = 3.sp,
modifier = Modifier.padding(bottom = 18.dp))
}
Ornament()
Text(book.name, color = bd.ink, fontFamily = Fonts.display, fontSize = 40.sp, lineHeight = 46.sp, textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 10.dp))
Ornament()
}
}
@Composable
fun Ornament() {
val bd = LocalBackdrop.current
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(70.dp).height(1.dp).background(bd.rubric.copy(alpha = 0.6f)))
Text(" ❦ ", color = bd.rubric, fontSize = 18.sp)
Box(Modifier.width(70.dp).height(1.dp).background(bd.rubric.copy(alpha = 0.6f)))
}
}
// ------------------------------------------------------------------ verse of the day card
@Composable
fun VotdCard(st: AppState) {
val bd = LocalBackdrop.current
val s = st.settings
val ref = remember { st.bible.parseRef(Votd.refFor())?.let { if (it.verse == 0) it.copy(verse = 1) else it } ?: Ref(43, 3, 16) }
val label = st.bible.refLabel(ref)
val text = st.bible.plainRange(s.tr, ref)
@Suppress("UNUSED_VARIABLE") val rev = st.user.rev.intValue
Column(
Modifier.widthIn(max = MAX_TEXT_WIDTH.dp).fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Panel(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("VERSE OF THE DAY", color = bd.rubric, fontFamily = Fonts.display, fontSize = 15.sp, letterSpacing = 2.sp)
Text(LocalDate.now().format(DateTimeFormatter.ofPattern("EEEE, MMMM d")), color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
Spacer(Modifier.height(12.dp))
Text(
"“$text”",
Modifier.clickable { st.goTo(ref) },
style = readingStyle(s, bd, 1.12f).copy(textAlign = TextAlign.Center, fontStyle = FontStyle.Italic)
)
Spacer(Modifier.height(10.dp))
Text(label + " · " + st.bible.trCode(s.tr), color = bd.rubric, fontFamily = Fonts.display, fontSize = 17.sp)
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Copy", icon = Icons.Filled.ContentCopy) { st.copy(text, label) }
SqButton("Image", icon = Icons.Filled.Image) { st.open(Screen.ShareImg(text, label)) }
SqButton("Open", icon = Icons.Filled.MenuBook) { st.goTo(ref) }
}
}
}
// reading plan nudge and continue button
val streak = remember(st.user.rev.intValue) { st.user.streak() }
val active = remember(st.user.rev.intValue) { Plans.all.firstOrNull { st.user.planStarted(it.id) != null } }
if (active != null || streak > 0) {
Spacer(Modifier.height(10.dp))
Panel(Modifier.fillMaxWidth().clickable { st.open(Screen.PlansScreen) }) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.LocalFireDepartment, null, tint = bd.rubric)
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Text(if (streak == 1) "1-day streak" else "$streak-day streak", color = bd.ink, fontFamily = UiFont, fontSize = 15.sp)
if (active != null) {
val days = remember(st.user.rev.intValue) { st.user.daysDone(active.id) }
val next = (1..active.days).firstOrNull { it !in days }
Text(if (next == null) "${active.name}: finished!" else "${active.name}: day $next of ${active.days}",
color = bd.faint, fontFamily = UiFont, fontSize = 13.sp)
}
}
Icon(Icons.Filled.ChevronRight, null, tint = bd.ink)
}
}
}
val last = s.lastItem
if (last > 1 && last < st.bible.items.size) {
val it = st.bible.items[last]
val lb = maxOf(1, Item.book(it)); val lc = maxOf(1, Item.chapter(it))
Spacer(Modifier.height(10.dp))
SqButton("Continue reading — ${st.bible.book(lb).name} $lc", icon = Icons.Filled.Bookmark, modifier = Modifier.fillMaxWidth()) {
st.goTo(Ref(lb, lc, maxOf(1, Item.verse(it))))
}
}
Spacer(Modifier.height(4.dp))
Text("Scroll down to begin at Genesis ↓", color = bd.faint, fontFamily = UiFont, fontSize = 12.sp, modifier = Modifier.padding(top = 10.dp))
}
}
// ------------------------------------------------------------------ scroll reader
private fun chapterSwipe(st: AppState, dx: Float) {
val b = st.bible
val target = if (dx < 0) b.nextChapter(st.currentBook, st.currentChapter) else b.prevChapter(st.currentBook, st.currentChapter)
target?.let { st.goTo(Ref(it.first, it.second, 0)) }
}
fun Modifier.chapterSwipes(st: AppState): Modifier = pointerInput(Unit) {
var total = 0f
var vertical = 0f
detectHorizontalDragGestures(
onDragStart = { total = 0f; vertical = 0f },
onDragEnd = { if (abs(total) > 140.dp.toPx()) chapterSwipe(st, total) },
onHorizontalDrag = { change, d -> total += d; change.consume() }
)
}
@Composable
fun ScrollReader(st: AppState) {
val b = st.bible
val s = st.settings
val start = remember {
when {
st.jumpTop -> { st.jumpTop = false; 0 }
st.jump != null -> 0
else -> b.indexOfVerse(st.currentBook, st.currentChapter, st.currentVerse).let { if (it <= 2 && s.lastItem > 1) s.lastItem else it }
}
}
val list = rememberLazyListState(initialFirstVisibleItemIndex = start)
val density = LocalDensity.current
// track where we are
LaunchedEffect(list) {
snapshotFlow { list.firstVisibleItemIndex }.collectLatest { idx ->
var i = idx
while (i < b.items.size - 1 && Item.kind(b.items[i]) < Item.CHAPTER) i++
val it = b.items[i]
st.currentBook = Item.book(it); st.currentChapter = Item.chapter(it); st.currentVerse = maxOf(1, Item.verse(it))
delay(600)
if (idx > 0) s.lastItem = idx
}
}
// jumps
LaunchedEffect(st.jump) {
val r = st.jump ?: return@LaunchedEffect
val idx = if (r.verse > 0) b.indexOfVerse(r.book, r.chapter, r.verse) - 1 else b.indexOfChapter(r.book, r.chapter)
list.scrollToItem(idx.coerceAtLeast(0))
st.jump = null
}
LaunchedEffect(st.jumpTop) {
if (st.jumpTop) { list.scrollToItem(0); st.jumpTop = false }
}
// auto-scroll
LaunchedEffect(st.autoScroll) {
if (!st.autoScroll) return@LaunchedEffect
var last = 0L
while (isActive && st.autoScroll) {
val dy = withFrameNanos { t ->
val d = if (last == 0L) 0f else (t - last) / 1e9f * with(density) { s.scrollSpeed.dp.toPx() }
last = t; d
}
if (list.isScrollInProgress) { last = 0L; continue }
try { list.scrollBy(dy) } catch (e: CancellationException) { if (!isActive) throw e; last = 0L }
if (!list.canScrollForward) st.autoScroll = false
}
}
// follow read-aloud
val cur = st.speech.current.value
LaunchedEffect(cur) {
if (cur != null && st.speech.speaking.value) {
val idx = b.indexOfVerse(cur.book, cur.chapter, cur.verse)
val visible = list.layoutInfo.visibleItemsInfo
val lastVisible = visible.lastOrNull()?.index ?: 0
if (idx < list.firstVisibleItemIndex || idx >= lastVisible - 1) list.animateScrollToItem((idx - 1).coerceAtLeast(0))
}
}
LazyColumn(
state = list,
modifier = Modifier.fillMaxSize().chapterSwipes(st),
contentPadding = PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 56.dp, bottom = 180.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(count = b.items.size, key = { b.items[it] }, contentType = { Item.kind(b.items[it]) }) { i ->
val it = b.items[i]
when (Item.kind(it)) {
Item.VOTD -> VotdCard(st)
Item.BOOK -> BookHeader(st, Item.book(it))
Item.CHAPTER -> ChapterHeader(st, Item.book(it), Item.chapter(it))
else -> VerseRow(st, Item.book(it), Item.chapter(it), Item.verse(it))
}
}
}
}
// ------------------------------------------------------------------ reel reader (one verse per screen)
@Composable
fun ReelReader(st: AppState) {
val b = st.bible
val s = st.settings
val start = remember {
if (st.jumpTop) st.jumpTop = false
b.pageOfVerse(st.currentBook, st.currentChapter, st.currentVerse)
}
val pager = rememberPagerState(initialPage = start) { b.verseItems.size }
LaunchedEffect(pager) {
snapshotFlow { pager.currentPage }.collectLatest { p ->
val it = b.verseItems[p]
st.currentBook = Item.book(it); st.currentChapter = Item.chapter(it); st.currentVerse = Item.verse(it)
delay(600)
s.lastItem = b.indexOfVerse(st.currentBook, st.currentChapter, st.currentVerse)
}
}
LaunchedEffect(st.jump) {
val r = st.jump ?: return@LaunchedEffect
pager.scrollToPage(b.pageOfVerse(r.book, r.chapter, maxOf(1, r.verse)))
st.jump = null
}
LaunchedEffect(st.autoScroll) {
while (st.autoScroll && isActive) {
delay(s.reelSeconds * 1000L)
if (!st.autoScroll) break
if (pager.currentPage < b.verseItems.size - 1) pager.animateScrollToPage(pager.currentPage + 1) else st.autoScroll = false
}
}
val cur = st.speech.current.value
LaunchedEffect(cur) {
if (cur != null && st.speech.speaking.value) {
val p = b.pageOfVerse(cur.book, cur.chapter, cur.verse)
if (p != pager.currentPage) pager.animateScrollToPage(p)
}
}
VerticalPager(
state = pager,
modifier = Modifier.fillMaxSize().chapterSwipes(st),
beyondViewportPageCount = 1,
key = { b.verseItems[it] }
) { p ->
val it = b.verseItems[p]
ReelPage(st, Item.book(it), Item.chapter(it), Item.verse(it))
}
}
@Composable
fun ReelPage(st: AppState, b: Int, c: Int, v: Int) {
val bd = LocalBackdrop.current
val s = st.settings
@Suppress("UNUSED_VARIABLE") val rev = st.user.rev.intValue
val ref = Ref(b, c, v)
val markup = st.bible.verse(s.tr, b, c, v) ?: ""
val hl = st.user.highlight(ref)
val onStrong: ((String, String) -> Unit)? = if (s.wordStudy && s.tr == 0) { id, w -> st.showSheet(Sheet.StrongsDef(id, w)) } else null
val text = remember(markup, s.jesusBlue, s.italics, bd, onStrong != null) { buildVerse(markup, v, s, bd, false, false, onStrong, null) }
val label = st.bible.refLabel(ref)
val plain = Markup.plain(markup)
val len = plain.length
val scale = when { len < 90 -> 1.6f; len < 180 -> 1.4f; len < 300 -> 1.2f; else -> 1.05f }
Box(Modifier.fillMaxSize().background(HighlightColors[hl].copy(alpha = HighlightColors[hl].alpha * 0.5f))) {
Column(
Modifier.align(Alignment.Center).widthIn(max = 700.dp).fillMaxSize().statusBarsPadding().padding(top = 60.dp, bottom = 120.dp, start = 26.dp, end = 84.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (v == 1) {
Text(if (b == 19) "PSALM $c" else "${st.bible.book(b).name.uppercase()} · CHAPTER $c",
color = bd.faint, fontFamily = Fonts.display, fontSize = 14.sp, letterSpacing = 2.sp)
Spacer(Modifier.height(16.dp))
}
Text(text, style = readingStyle(s, bd, scale).copy(textAlign = TextAlign.Center))
Spacer(Modifier.height(18.dp))
Ornament()
Spacer(Modifier.height(8.dp))
Text(label, color = bd.rubric, fontFamily = Fonts.display, fontSize = 20.sp)
st.user.note(ref)?.let { n ->
Spacer(Modifier.height(14.dp))
Text("✎ " + n.text, Modifier.clickable { st.showSheet(Sheet.NoteEdit(ref)) }, color = bd.faint, fontFamily = UiFont, fontSize = 14.sp, textAlign = TextAlign.Center)
}
}
// TikTok-style action column
Column(
Modifier.align(Alignment.CenterEnd).padding(end = 10.dp, bottom = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
ReelAction(if (hl > 0) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, if (hl > 0) "Saved" else "Love", tint = if (hl > 0) bd.rubric else null) {
st.user.setHighlight(listOf(ref), if (hl > 0) 0 else 4)
}
ReelAction(if (st.user.isBookmarked(ref)) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, "Mark") { st.user.toggleBookmark(ref) }
ReelAction(Icons.Filled.EditNote, "Note") { st.showSheet(Sheet.NoteEdit(ref)) }
ReelAction(Icons.Filled.Image, "Image") { st.open(Screen.ShareImg(plain, label)) }
ReelAction(Icons.Filled.ContentCopy, "Copy") { st.copy(plain, label) }
ReelAction(Icons.Filled.MoreHoriz, "More") { st.selection.clear(); st.selection.add(ref) }
}
Text("${st.bible.book(b).abbr} $c:$v · swipe up ↑", Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(bottom = 18.dp),
color = bd.faint.copy(alpha = 0.8f), fontFamily = UiFont, fontSize = 12.sp)
}
}
@Composable
fun ReelAction(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color? = null, onClick: () -> Unit) {
val bd = LocalBackdrop.current
Column(Modifier.clickable(onClick = onClick).padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(46.dp).background(bd.panel).border(1.dp, bd.ink.copy(alpha = 0.5f)), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = tint ?: bd.ink, modifier = Modifier.size(24.dp))
}
Text(label, color = bd.ink, fontFamily = UiFont, fontSize = 11.sp)
}
}
// ------------------------------------------------------------------ bottom bars
@Composable
fun SelectionBar(st: AppState) {
val bd = LocalBackdrop.current
val refs = st.sortedSelection()
val label = st.labelFor(refs)
var colors by remember { mutableStateOf(false) }
Panel(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.weight(1f), color = bd.rubric, fontFamily = Fonts.display, fontSize = 18.sp, maxLines = 1)
IconBtn(Icons.Filled.Close, "Clear") { st.selection.clear() }
}
if (colors) {
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (i in 0 until HighlightColors.size) {
Box(
Modifier.weight(1f).height(38.dp)
.background(if (i == 0) Color.Transparent else HighlightColors[i].copy(alpha = 0.85f))
.border(1.dp, bd.ink.copy(alpha = 0.6f))
.clickable { st.user.setHighlight(refs, i); colors = false; st.selection.clear() },
contentAlignment = Alignment.Center
) { if (i == 0) Icon(Icons.Filled.FormatColorReset, "Remove highlight", tint = bd.ink) }
}
}
}
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp)) {
val text = st.textFor(refs)
ToolButton("Copy", Icons.Filled.ContentCopy) { st.copy(text, label); st.selection.clear() }
ToolButton("Image", Icons.Filled.Image) { st.open(Screen.ShareImg(text, label)) }
ToolButton("Highlight", Icons.Filled.BorderColor, active = colors) { colors = !colors }
ToolButton("Note", Icons.Filled.EditNote) { st.showSheet(Sheet.NoteEdit(refs.first())) }
ToolButton("Bookmark", Icons.Filled.Bookmark) { refs.forEach { st.user.toggleBookmark(it) }; st.selection.clear() }
ToolButton("Compare", Icons.Filled.ViewColumn) { st.showSheet(Sheet.Compare(refs)) }
ToolButton("Cross refs", Icons.Filled.Link) { st.showSheet(Sheet.Xrefs(refs.first())) }
ToolButton("Words", Icons.Filled.Translate) { st.showSheet(Sheet.StrongsWords(refs.first())) }
ToolButton("Listen", Icons.Filled.Headphones) { st.speech.start(refs.first()); st.selection.clear() }
ToolButton("Share", Icons.Filled.Share) { st.shareText(text, label) }
}
}
}
@Composable
fun AutoScrollBar(st: AppState) {
val bd = LocalBackdrop.current
val s = st.settings
Panel(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
IconBtn(Icons.Filled.Pause, "Stop auto-scroll") { st.autoScroll = false }
Text(
if (s.mode == 0) "Auto-scroll · speed ${s.scrollSpeed.toInt()}" else "Auto-advance · every ${s.reelSeconds}s",
Modifier.weight(1f), color = bd.ink, fontFamily = UiFont, fontSize = 14.sp
)
IconBtn(Icons.Filled.Remove, "Slower") {
if (s.mode == 0) s.scrollSpeed = (s.scrollSpeed - 5f).coerceAtLeast(5f) else s.reelSeconds = (s.reelSeconds + 1).coerceAtMost(30)
}
IconBtn(Icons.Filled.Add, "Faster") {
if (s.mode == 0) s.scrollSpeed = (s.scrollSpeed + 5f).coerceAtMost(200f) else s.reelSeconds = (s.reelSeconds - 1).coerceAtLeast(2)
}
}
}
}
@Composable
fun SpeechBar(st: AppState) {
val bd = LocalBackdrop.current
val cur = st.speech.current.value
val sleepAt = st.speech.sleepAt.longValue
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(sleepAt) { while (sleepAt > 0) { now = System.currentTimeMillis(); delay(15_000) } }
Panel(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
IconBtn(Icons.Filled.Stop, "Stop reading") { st.speech.stop() }
Column(Modifier.weight(1f).clickable { st.showSheet(Sheet.ReadAloud) }) {
Text("Reading aloud · " + (cur?.let { st.bible.refLabel(it) } ?: ""), color = bd.ink, fontFamily = UiFont, fontSize = 14.sp, maxLines = 1)
if (sleepAt > 0) Text("Sleep timer: ${((sleepAt - now) / 60000 + 1).coerceAtLeast(0)} min left", color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
}
IconBtn(Icons.Filled.Bedtime, "Sleep timer") { st.showSheet(Sheet.ReadAloud) }
}
}
}