Add app/src/main/java/space/bloggin/vellum
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user