From 55b964aada07e8dbb9af27ead3c31a476bed1d30 Mon Sep 17 00:00:00 2001 From: weishaupt <1+weishaupt@noreply.localhost> Date: Mon, 19 Oct 2026 01:20:24 +0000 Subject: [PATCH] Add app/src/main/java/space/bloggin/vellum --- .../main/java/space/bloggin/vellum/Bible.kt | 240 +++++++ .../main/java/space/bloggin/vellum/Content.kt | 103 +++ .../java/space/bloggin/vellum/MainActivity.kt | 186 +++++ .../main/java/space/bloggin/vellum/Markup.kt | 71 ++ .../main/java/space/bloggin/vellum/Reader.kt | 660 ++++++++++++++++++ 5 files changed, 1260 insertions(+) create mode 100644 app/src/main/java/space/bloggin/vellum/Bible.kt create mode 100644 app/src/main/java/space/bloggin/vellum/Content.kt create mode 100644 app/src/main/java/space/bloggin/vellum/MainActivity.kt create mode 100644 app/src/main/java/space/bloggin/vellum/Markup.kt create mode 100644 app/src/main/java/space/bloggin/vellum/Reader.kt diff --git a/app/src/main/java/space/bloggin/vellum/Bible.kt b/app/src/main/java/space/bloggin/vellum/Bible.kt new file mode 100644 index 0000000..bb03204 --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Bible.kt @@ -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 + val translations: List + /** verse counts per (book, chapter) using KJV versification */ + private val verseCounts = HashMap() + /** 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() + private val bookIndex = HashMap() + /** every verse in order, for reel mode and reading plans */ + val verseItems: IntArray + private val chapterVerseStart = HashMap() + private val titles = HashMap() + + 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(33000) + val vlist = ArrayList(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? = when { + c < book(b).chapters -> b to c + 1 + b < 66 -> b + 1 to 1 + else -> null + } + fun prevChapter(b: Int, c: Int): Pair? = 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>(160) {} + + /** Verse markup for a chapter; index 0 = verse 1. */ + fun chapter(tr: Int, b: Int, c: Int): Array { + val key = (tr.toLong() shl 32) or (b * 1000L + c) + chapterCache.get(key)?.let { return it } + val n = verseCount(b, c) + val arr = arrayOfNulls(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 { + 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() + val args = ArrayList() + 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 = + 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 = + 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) + } + } +} diff --git a/app/src/main/java/space/bloggin/vellum/Content.kt b/app/src/main/java/space/bloggin/vellum/Content.kt new file mode 100644 index 0000000..99055b6 --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Content.kt @@ -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) + +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 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) +data class Plan(val id: String, val name: String, val blurb: String, val days: Int, val build: (Bible) -> List>) + +object Plans { + private fun split(chs: List, days: Int): List> = + (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): String { + if (chs.isEmpty()) return "" + val groups = ArrayList>() + 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}" + } + } +} diff --git a/app/src/main/java/space/bloggin/vellum/MainActivity.kt b/app/src/main/java/space/bloggin/vellum/MainActivity.kt new file mode 100644 index 0000000..ccadc06 --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/MainActivity.kt @@ -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) : 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 = mutableStateListOf() + var sheet by mutableStateOf(null) + var sheetStack = mutableStateListOf() + val selection: SnapshotStateList = mutableStateListOf() + /** a jump the reader should perform; reader clears it */ + var jump by mutableStateOf(null) + var jumpTop by mutableStateOf(false) + /** briefly glows after a jump */ + var flash by mutableStateOf(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): 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() + 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, 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() + } +} diff --git a/app/src/main/java/space/bloggin/vellum/Markup.kt b/app/src/main/java/space/bloggin/vellum/Markup.kt new file mode 100644 index 0000000..522bf2b --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Markup.kt @@ -0,0 +1,71 @@ +package space.bloggin.vellum + +/** + * Verse markup: {words|H430} Strong's group, [words] translator italics, 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 { + val out = ArrayList() + 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> = + parse(s).filter { it.strong != null }.map { it.text to it.strong!! }.distinct() +} diff --git a/app/src/main/java/space/bloggin/vellum/Reader.kt b/app/src/main/java/space/bloggin/vellum/Reader.kt new file mode 100644 index 0000000..6be3cfa --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Reader.kt @@ -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) } + } + } +}