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

This commit is contained in:
2026-10-19 01:20:26 +00:00
parent 55b964aada
commit 29fb869421
5 changed files with 1399 additions and 0 deletions
@@ -0,0 +1,637 @@
package space.bloggin.vellum
import android.Manifest
import android.app.TimePickerDialog
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@Composable
fun ScreenFrame(st: AppState, title: String, actions: @Composable RowScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
val bd = LocalBackdrop.current
Box(Modifier.fillMaxSize().clickable(enabled = false) {}) {
BackdropFill(bd)
Column(Modifier.fillMaxSize()) {
Row(
Modifier.fillMaxWidth().background(bd.panel).statusBarsPadding().height(54.dp).padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconBtn(Icons.Filled.ArrowBack, "Back") { st.back() }
Text(title, Modifier.weight(1f).padding(start = 4.dp), color = bd.ink, fontFamily = Fonts.display, fontSize = 22.sp, maxLines = 1)
actions()
}
Rule()
Column(Modifier.fillMaxSize().navigationBarsPadding().imePadding(), horizontalAlignment = Alignment.CenterHorizontally) {
Column(Modifier.widthIn(max = 820.dp).fillMaxSize(), content = content)
}
}
}
}
@Composable
fun ScreenHost(st: AppState, s: Screen) {
when (s) {
Screen.GoTo -> GoToScreen(st)
Screen.Search -> SearchScreen(st)
is Screen.Library -> LibraryScreen(st, s.tab)
Screen.PlansScreen -> PlansScreen(st)
Screen.TopicsScreen -> TopicsScreen(st)
is Screen.TopicDetail -> TopicDetailScreen(st, s.topic)
Screen.Appearance -> AppearanceScreen(st)
Screen.SettingsScreen -> SettingsScreen(st)
Screen.About -> AboutScreen(st)
is Screen.ShareImg -> ShareImageScreen(st, s.text, s.label)
}
}
// ------------------------------------------------------------------ go to
@Composable
fun GoToScreen(st: AppState) {
val bd = LocalBackdrop.current
var book by remember { mutableStateOf<Book?>(null) }
var typed by remember { mutableStateOf("") }
ScreenFrame(st, book?.name ?: "Go to") {
if (book == null) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
InputBox(typed, { typed = it }, "Type a reference, e.g. John 3:16", Modifier.weight(1f), onGo = {
st.bible.parseRef(typed)?.let { st.goTo(it) } ?: st.toast("Couldn't read that reference")
})
Spacer(Modifier.width(8.dp))
SqButton("Go", filled = true) { st.bible.parseRef(typed)?.let { st.goTo(it) } ?: st.toast("Couldn't read that reference") }
}
LazyVerticalGrid(GridCells.Adaptive(104.dp), Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Old Testament") }
items(st.bible.books.take(39)) { b -> BookCell(st, b) { if (b.chapters == 1) st.goTo(Ref(b.id, 1, 0)) else book = b } }
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("New Testament") }
items(st.bible.books.drop(39)) { b -> BookCell(st, b) { if (b.chapters == 1) st.goTo(Ref(b.id, 1, 0)) else book = b } }
}
} else {
val b = book!!
LazyVerticalGrid(GridCells.Adaptive(60.dp), Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(verticalAlignment = Alignment.CenterVertically) {
SqButton("All books", icon = Icons.Filled.ArrowBack) { book = null }
}
}
items((1..b.chapters).toList()) { c ->
val current = st.currentBook == b.id && st.currentChapter == c
Box(
Modifier.aspectRatio(1f).background(if (current) bd.ink else Color.Transparent).border(1.dp, bd.ink.copy(alpha = 0.6f))
.clickable { st.goTo(Ref(b.id, c, 0)) },
contentAlignment = Alignment.Center
) { Text("$c", color = if (current) inkOn(bd) else bd.ink, fontFamily = UiFont, fontSize = 18.sp) }
}
}
}
}
}
@Composable
fun BookCell(st: AppState, b: Book, onClick: () -> Unit) {
val bd = LocalBackdrop.current
val current = st.currentBook == b.id
Box(
Modifier.height(54.dp).background(if (current) bd.ink else bd.panel.copy(alpha = 0.6f)).border(1.dp, bd.ink.copy(alpha = 0.55f)).clickable(onClick = onClick).padding(4.dp),
contentAlignment = Alignment.Center
) {
Text(b.name, color = if (current) inkOn(bd) else bd.ink, fontFamily = UiFont, fontSize = 14.sp, textAlign = TextAlign.Center, lineHeight = 16.sp, maxLines = 2)
}
}
@Composable
fun InputBox(value: String, onChange: (String) -> Unit, hint: String, modifier: Modifier = Modifier, focus: FocusRequester? = null, onGo: () -> Unit) {
val bd = LocalBackdrop.current
BasicTextField(
value = value, onValueChange = onChange, singleLine = true,
modifier = modifier.border(1.dp, bd.ink.copy(alpha = 0.7f)).background(bd.panel).padding(horizontal = 12.dp, vertical = 11.dp)
.let { if (focus != null) it.focusRequester(focus) else it },
textStyle = uiStyle(bd, 17),
cursorBrush = SolidColor(bd.ink),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { onGo() }, onSearch = { onGo() }, onDone = { onGo() }),
decorationBox = { inner ->
if (value.isEmpty()) Text(hint, color = bd.faint, fontFamily = UiFont, fontSize = 17.sp, maxLines = 1)
inner()
}
)
}
// ------------------------------------------------------------------ search
@Composable
fun SearchScreen(st: AppState) {
val bd = LocalBackdrop.current
var q by remember { mutableStateOf("") }
var scope by remember { mutableIntStateOf(0) }
var results by remember { mutableStateOf<List<SearchHit>>(emptyList()) }
var busy by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { try { focus.requestFocus() } catch (_: Exception) {} }
LaunchedEffect(q, scope, st.settings.tr) {
delay(350)
if (q.trim().length < 2) { results = emptyList(); return@LaunchedEffect }
busy = true
results = withContext(Dispatchers.IO) { st.bible.search(st.settings.tr, q, scope) }
busy = false
}
val asRef = remember(q) { st.bible.parseRef(q) }
ScreenFrame(st, "Search") {
InputBox(q, { q = it }, "Words, a \"phrase\", or a reference", Modifier.fillMaxWidth().padding(12.dp), focus) {}
Row(Modifier.padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
Chip("Whole Bible", scope == 0) { scope = 0 }
Chip("Old Testament", scope == 1) { scope = 1 }
Chip("New Testament", scope == 2) { scope = 2 }
}
Text(
when {
busy -> "Searching…"
q.trim().length < 2 -> "Search the ${st.bible.trCode(st.settings.tr)} text. Put words in quotes to match an exact phrase."
results.size >= 500 -> "First 500 verses"
else -> "${results.size} verse" + (if (results.size == 1) "" else "s")
},
Modifier.padding(horizontal = 14.dp, vertical = 8.dp), color = bd.faint, fontFamily = UiFont, fontSize = 13.sp
)
LazyColumn(Modifier.fillMaxSize()) {
if (asRef != null) item {
Row(Modifier.fillMaxWidth().clickable { st.goTo(asRef) }.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.MenuBook, null, tint = bd.rubric)
Spacer(Modifier.width(10.dp))
Text("Go to " + st.bible.refLabel(asRef), color = bd.ink, fontFamily = UiFont, fontSize = 17.sp, fontWeight = FontWeight.Bold)
}
Rule(alpha = 0.2f)
}
items(results) { h ->
Column(Modifier.fillMaxWidth().clickable { st.goTo(h.ref) }.padding(horizontal = 14.dp, vertical = 9.dp)) {
Text(st.bible.refLabel(h.ref), color = bd.rubric, fontFamily = UiFont, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text(highlightMatches(h.text, q, bd.rubric), color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 17.sp, lineHeight = 24.sp)
}
Rule(Modifier.padding(horizontal = 14.dp), alpha = 0.15f)
}
}
}
}
// ------------------------------------------------------------------ library
@Composable
fun LibraryScreen(st: AppState, startTab: Int) {
val bd = LocalBackdrop.current
var tab by remember { mutableIntStateOf(startTab) }
val rev = st.user.rev.intValue
ScreenFrame(st, "Your marks") {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Chip("Bookmarks", tab == 0) { tab = 0 }
Chip("Highlights", tab == 1) { tab = 1 }
Chip("Notes", tab == 2) { tab = 2 }
}
val font = Fonts.all[st.settings.font].family
when (tab) {
0 -> {
val list = remember(rev) { st.user.allBookmarks() }
if (list.isEmpty()) Empty("No bookmarks yet. Tap a verse, then Bookmark.")
LazyColumn {
items(list) { m ->
MarkRow(st, m.ref, st.bible.plainVerse(st.settings.tr, m.ref.book, m.ref.chapter, m.ref.verse), null, font) { st.user.toggleBookmark(m.ref) }
}
}
}
1 -> {
val list = remember(rev) { st.user.allHighlights() }
if (list.isEmpty()) Empty("No highlights yet. Tap a verse, then Highlight.")
LazyColumn {
items(list) { (r, c) ->
MarkRow(st, r, st.bible.plainVerse(st.settings.tr, r.book, r.chapter, r.verse), HighlightColors[c], font) { st.user.setHighlight(listOf(r), 0) }
}
}
}
else -> {
val list = remember(rev) { st.user.allNotes() }
if (list.isEmpty()) Empty("No notes yet. Tap a verse, then Note.")
LazyColumn {
items(list) { n ->
Column(Modifier.fillMaxWidth().clickable { st.goTo(n.ref) }.padding(horizontal = 14.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(st.bible.refLabel(n.ref), Modifier.weight(1f), color = bd.rubric, fontFamily = UiFont, fontWeight = FontWeight.Bold, fontSize = 14.sp)
IconBtn(Icons.Filled.Edit, "Edit", size = 18) { st.showSheet(Sheet.NoteEdit(n.ref)) }
}
Text(n.text, color = bd.ink, fontFamily = UiFont, fontSize = 16.sp)
Text(st.bible.plainVerse(st.settings.tr, n.ref.book, n.ref.chapter, n.ref.verse), color = bd.faint, fontFamily = font, fontStyle = FontStyle.Italic, fontSize = 15.sp, maxLines = 2)
}
Rule(Modifier.padding(horizontal = 14.dp), alpha = 0.15f)
}
}
}
}
}
}
@Composable
fun Empty(text: String) {
val bd = LocalBackdrop.current
Text(text, Modifier.fillMaxWidth().padding(30.dp), color = bd.faint, fontFamily = UiFont, fontSize = 15.sp, textAlign = TextAlign.Center)
}
@Composable
fun MarkRow(st: AppState, r: Ref, text: String, color: Color?, font: androidx.compose.ui.text.font.FontFamily, onRemove: () -> Unit) {
val bd = LocalBackdrop.current
Row(Modifier.fillMaxWidth().clickable { st.goTo(r) }.padding(start = 14.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
if (color != null) Box(Modifier.width(6.dp).height(44.dp).background(color.copy(alpha = 1f)))
Column(Modifier.weight(1f).padding(start = if (color != null) 10.dp else 0.dp)) {
Text(st.bible.refLabel(r), color = bd.rubric, fontFamily = UiFont, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Text(text, color = bd.ink, fontFamily = font, fontSize = 16.sp, maxLines = 3)
}
IconBtn(Icons.Filled.Close, "Remove", size = 18, onClick = onRemove)
}
Rule(Modifier.padding(horizontal = 14.dp), alpha = 0.15f)
}
// ------------------------------------------------------------------ plans
@Composable
fun PlansScreen(st: AppState) {
val bd = LocalBackdrop.current
val rev = st.user.rev.intValue
val streak = remember(rev) { st.user.streak() }
val best = remember(rev) { st.user.bestStreak() }
ScreenFrame(st, "Reading plans") {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
item {
Panel(Modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.LocalFireDepartment, null, tint = bd.rubric, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(12.dp))
Column {
Text(if (streak == 1) "1-day streak" else "$streak-day streak", color = bd.ink, fontFamily = Fonts.display, fontSize = 24.sp)
Text("Best: $best day" + (if (best == 1) "" else "s") + ". Finish a day's reading to keep it going.", color = bd.faint, fontFamily = UiFont, fontSize = 13.sp)
}
}
}
}
items(Plans.all) { p -> PlanCard(st, p, rev) }
}
}
}
@Composable
fun PlanCard(st: AppState, p: Plan, rev: Int) {
val bd = LocalBackdrop.current
val started = remember(rev) { st.user.planStarted(p.id) }
val days = remember(p.id) { p.build(st.bible) }
val done = remember(rev) { st.user.daysDone(p.id) }
var showAll by remember { mutableStateOf(false) }
Panel(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(p.name, color = bd.ink, fontFamily = Fonts.display, fontSize = 21.sp)
Text(p.blurb + " · ${p.days} days", color = bd.faint, fontFamily = UiFont, fontSize = 13.sp)
if (started == null) {
Spacer(Modifier.height(10.dp))
SqButton("Start this plan", icon = Icons.Filled.PlayArrow, filled = true) { st.user.startPlan(p.id) }
} else {
val next = (1..p.days).firstOrNull { it !in done }
val todayNum = Plans.today(started).coerceAtMost(p.days)
Spacer(Modifier.height(10.dp))
// progress bar
Box(Modifier.fillMaxWidth().height(8.dp).border(1.dp, bd.ink.copy(alpha = 0.5f))) {
Box(Modifier.fillMaxWidth(done.size / p.days.toFloat()).fillMaxHeight().background(bd.rubric))
}
Text("${done.size} of ${p.days} days done" + when {
next == null -> " · finished!"
next < todayNum -> " · ${todayNum - next} behind"
next > todayNum -> " · ahead of schedule"
else -> " · on schedule"
}, color = bd.faint, fontFamily = UiFont, fontSize = 13.sp, modifier = Modifier.padding(top = 4.dp))
if (next != null) {
val chs = days[next - 1]
Spacer(Modifier.height(10.dp))
Text("Day $next", color = bd.rubric, fontFamily = Fonts.display, fontSize = 17.sp)
Text(Plans.label(st.bible, chs), color = bd.ink, fontFamily = UiFont, fontSize = 16.sp)
Row(Modifier.padding(top = 8.dp).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
for (c in chs) {
val label = (if (c / 1000 == 19) "Ps" else st.bible.book(c / 1000).abbr) + " " + c % 1000
Chip(label, false) { st.goTo(Ref(c / 1000, c % 1000, 0)) }
}
}
Spacer(Modifier.height(10.dp))
SqButton("Mark day $next done", icon = Icons.Filled.Check, filled = true) { st.user.setDayDone(p.id, next, true) }
}
Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton(if (showAll) "Hide days" else "All days", icon = Icons.Filled.List) { showAll = !showAll }
SqButton("Stop plan", icon = Icons.Filled.Close) { st.user.stopPlan(p.id) }
}
if (showAll) {
Column(Modifier.padding(top = 8.dp)) {
for (d in 1..p.days) {
val isDone = d in done
Row(Modifier.fillMaxWidth().clickable { st.user.setDayDone(p.id, d, !isDone) }.padding(vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(if (isDone) Icons.Filled.CheckBox else Icons.Filled.CheckBoxOutlineBlank, null, tint = bd.ink, modifier = Modifier.size(20.dp))
Text(" Day $d ", color = bd.rubric, fontFamily = UiFont, fontSize = 14.sp)
Text(Plans.label(st.bible, days[d - 1]), color = if (isDone) bd.faint else bd.ink, fontFamily = UiFont, fontSize = 14.sp, maxLines = 1)
}
}
}
}
}
}
}
}
// ------------------------------------------------------------------ topics
@Composable
fun TopicsScreen(st: AppState) {
val bd = LocalBackdrop.current
ScreenFrame(st, "Verses by topic") {
LazyVerticalGrid(GridCells.Adaptive(160.dp), Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(Topics.all) { t ->
Panel(Modifier.clickable { st.open(Screen.TopicDetail(t)) }) {
Column(Modifier.fillMaxWidth().height(100.dp).padding(12.dp), verticalArrangement = Arrangement.Center) {
Text(t.name, color = bd.ink, fontFamily = Fonts.display, fontSize = 19.sp, lineHeight = 21.sp)
Text(t.blurb, color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
}
}
}
}
}
}
@Composable
fun TopicDetailScreen(st: AppState, t: Topic) {
val bd = LocalBackdrop.current
val refs = remember(t) { t.refs.mapNotNull { st.bible.parseRef(it)?.let { r -> if (r.verse == 0) r.copy(verse = 1) else r } } }
ScreenFrame(st, t.name) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 8.dp)) {
item { Text(t.blurb, Modifier.padding(horizontal = 16.dp, vertical = 6.dp), color = bd.faint, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 17.sp) }
items(refs) { r ->
val label = st.bible.refLabel(r)
val text = st.bible.plainRange(st.settings.tr, r)
Column(Modifier.fillMaxWidth().clickable { st.goTo(r) }.padding(horizontal = 16.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.weight(1f), color = bd.rubric, fontFamily = Fonts.display, fontSize = 17.sp)
IconBtn(Icons.Filled.ContentCopy, "Copy", size = 18) { st.copy(text, label) }
IconBtn(Icons.Filled.Image, "Image", size = 18) { st.open(Screen.ShareImg(text, label)) }
}
Text(text, style = readingStyle(st.settings, bd, 0.92f))
}
Rule(Modifier.padding(horizontal = 16.dp), alpha = 0.15f)
}
}
}
}
// ------------------------------------------------------------------ appearance
@Composable
fun AppearanceScreen(st: AppState) {
val bd = LocalBackdrop.current
val s = st.settings
ScreenFrame(st, "Font, size & background") {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) {
// live preview
Spacer(Modifier.height(12.dp))
Panel(Modifier.fillMaxWidth()) {
val preview = remember(s.jesusBlue, s.italics, bd) {
buildVerse("<{Come|G1205} {unto|G4314} {me|G3165}, {all|G3956} [ye] {that labour|G2872} {and|G2532} {are heavy laden|G5412}, {and I|G2504} {will give|G373} {you|G5209} {rest|G373}.>",
28, s, bd, true, false, null, null)
}
Text(preview, Modifier.padding(16.dp), style = readingStyle(s, bd))
}
SectionLabel("Reading font")
Fonts.all.forEachIndexed { i, f ->
Row(Modifier.fillMaxWidth().background(if (s.font == i) bd.ink.copy(alpha = 0.1f) else Color.Transparent)
.border(1.dp, if (s.font == i) bd.ink else Color.Transparent).clickable { s.font = i }.padding(12.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(f.name, Modifier.weight(1f), color = bd.ink, fontFamily = f.family, fontSize = 20.sp)
if (s.font == i) Icon(Icons.Filled.Check, null, tint = bd.ink)
}
}
SectionLabel("Size & spacing")
SliderRow("Text size", s.size, 14f..36f, "${s.size.toInt()}") { s.size = it }
SliderRow("Line spacing", s.spacing, 1.2f..2.2f, "%.2f".format(s.spacing)) { s.spacing = it }
SectionLabel("Background")
ToggleRow("Night mode", s.night, "Dark, warm, easy on the eyes in bed") { s.night = it }
if (s.night) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (i in Backdrops.nightChoices) Swatch(Backdrops.all[i], s.nightBg == i, Modifier.weight(1f)) { s.nightBg = i }
}
} else {
val day = Backdrops.all.indices.filter { !Backdrops.all[it].dark }
day.chunked(4).forEach { row ->
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { i -> Swatch(Backdrops.all[i], s.bg == i, Modifier.weight(1f)) { s.bg = i } }
repeat(4 - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
SectionLabel("Text")
ToggleRow("Words of Jesus in blue", s.jesusBlue, "KJV and WEB") { s.jesusBlue = it }
ToggleRow("Translator's italics", s.italics, "KJV prints words the translators added in italics") { s.italics = it }
ToggleRow("Word study (tap a word)", s.wordStudy, "KJV only: underlined words open the Hebrew or Greek") { s.wordStudy = it }
SectionLabel("Layout")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Chip("Scroll", s.mode == 0) { s.mode = 0 }
Chip("Reel (one verse a screen)", s.mode == 1) { s.mode = 1 }
}
Text("Swipe left or right in either layout to change chapter.", Modifier.padding(vertical = 8.dp), color = bd.faint, fontFamily = UiFont, fontSize = 13.sp)
Spacer(Modifier.height(40.dp))
}
}
}
@Composable
fun Swatch(b: Backdrop, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val bd = LocalBackdrop.current
Column(modifier.clickable(onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.fillMaxWidth().aspectRatio(0.8f).border(if (selected) 3.dp else 1.dp, if (selected) bd.rubric else bd.ink.copy(alpha = 0.5f))) {
BackdropFill(b)
Text("Aa", Modifier.align(Alignment.Center), color = b.ink, fontFamily = Fonts.all[0].family, fontSize = 22.sp)
}
Text(b.name, color = bd.ink, fontFamily = UiFont, fontSize = 11.sp, maxLines = 1, textAlign = TextAlign.Center)
}
}
// ------------------------------------------------------------------ settings
@Composable
fun SettingsScreen(st: AppState) {
val bd = LocalBackdrop.current
val s = st.settings
val ctx = LocalContext.current
val askPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { ok ->
s.notify = ok
if (!ok) st.toast("Notifications are turned off for Vellum in Android settings")
Daily.schedule(ctx)
}
ScreenFrame(st, "Settings") {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) {
SectionLabel("Opening the app")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Chip("Verse of the Day", s.openAtVotd) { s.openAtVotd = true }
Chip("Where I left off", !s.openAtVotd) { s.openAtVotd = false }
}
SectionLabel("Daily verse notification")
ToggleRow("Send me the verse of the day", s.notify, "Every morning at %d:%02d %s".format(
(if (s.notifyHour % 12 == 0) 12 else s.notifyHour % 12), s.notifyMinute, if (s.notifyHour < 12) "AM" else "PM")) { on ->
if (on && Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
askPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} else { s.notify = on; Daily.schedule(ctx) }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Change time", icon = Icons.Filled.Schedule) {
TimePickerDialog(ctx, { _, h, m -> s.notifyHour = h; s.notifyMinute = m; Daily.schedule(ctx) }, s.notifyHour, s.notifyMinute, false).show()
}
SqButton("Send one now", icon = Icons.Filled.NotificationsActive) {
if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
askPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
else Daily.notifyNow(ctx)
}
}
Text("There's also a Verse of the Day home screen widget: long-press your home screen, tap Widgets, and look for Vellum.",
Modifier.padding(top = 10.dp), color = bd.faint, fontFamily = UiFont, fontSize = 13.sp)
SectionLabel("Read aloud & auto-scroll")
SliderRow("Voice speed", s.ttsRate, 0.5f..1.6f, "%.2fx".format(s.ttsRate)) { s.ttsRate = it }
SliderRow("Scroll speed", s.scrollSpeed, 5f..200f, "${s.scrollSpeed.toInt()}") { s.scrollSpeed = it }
SliderRow("Reel: seconds per verse", s.reelSeconds.toFloat(), 2f..30f, "${s.reelSeconds}s", steps = 27) { s.reelSeconds = it.toInt() }
ToggleRow("Keep screen on", s.keepAwake, "While auto-scrolling or reading aloud") { s.keepAwake = it }
SectionLabel("Privacy")
Text("No account, no ads, no tracking, no internet permission. Your bookmarks, highlights, notes and plan progress never leave this phone.",
color = bd.ink, fontFamily = UiFont, fontSize = 14.sp)
Spacer(Modifier.height(40.dp))
}
}
}
@Composable
fun AboutScreen(st: AppState) {
val bd = LocalBackdrop.current
ScreenFrame(st, "About & credits") {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Text("Vellum", color = bd.ink, fontFamily = Fonts.display, fontSize = 36.sp)
Text("A quiet Bible reader. Version 1.0", color = bd.faint, fontFamily = UiFont, fontSize = 14.sp)
SectionLabel("Texts")
listOf(
"King James Version (1769), with Strong's numbers and red-letter markup from eBible.org (public domain)",
"World English Bible (public domain), eBible.org",
"Berean Standard Bible (public domain)",
"American Standard Version 1901 (public domain)",
"Young's Literal Translation 1898 (public domain)",
"Douay-Rheims, Challoner revision (public domain)",
).forEach { Text("• $it", color = bd.ink, fontFamily = UiFont, fontSize = 14.sp, modifier = Modifier.padding(vertical = 2.dp)) }
SectionLabel("Study helps")
Text("• Strong's Hebrew and Greek dictionaries: Open Scriptures (openscriptures.org), CC BY-SA", color = bd.ink, fontFamily = UiFont, fontSize = 14.sp)
Text("• Cross references: openbible.info, CC BY", color = bd.ink, fontFamily = UiFont, fontSize = 14.sp)
SectionLabel("Fonts")
Text("Tinos (Apache 2.0), EB Garamond, Crimson Pro, IM Fell English and Cardo (SIL Open Font License).", color = bd.ink, fontFamily = UiFont, fontSize = 14.sp)
SectionLabel("Artwork")
Text("The vellum, papyrus, parchment and candlelight backgrounds are generated for this app; no borrowed images.", color = bd.ink, fontFamily = UiFont, fontSize = 14.sp)
}
}
}
// ------------------------------------------------------------------ share as image
@Composable
fun ShareImageScreen(st: AppState, initialText: String, label: String) {
val bd = LocalBackdrop.current
val ctx = LocalContext.current
var text by remember { mutableStateOf(initialText) }
var style by remember { mutableStateOf(ImageStyle(backdrop = st.settings.bg.takeIf { !Backdrops.all[it].dark } ?: 0, font = st.settings.font)) }
var bmp by remember { mutableStateOf<Bitmap?>(null) }
val refText = label + if (style.showTranslation) " (${st.bible.trCode(st.settings.tr)})" else ""
LaunchedEffect(text, style) {
delay(120)
bmp = withContext(Dispatchers.Default) { ShareImage.render(ctx, text, refText, style) }
}
ScreenFrame(st, "Share as image") {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 14.dp)) {
Spacer(Modifier.height(12.dp))
Box(Modifier.fillMaxWidth().aspectRatio(style.shape.w / style.shape.h.toFloat()).heightIn(max = 520.dp).border(1.dp, bd.ink.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) {
bmp?.let { Image(it.asImageBitmap(), "Preview", Modifier.fillMaxSize(), contentScale = ContentScale.Fit) }
}
Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SqButton("Save to Photos", icon = Icons.Filled.Download, modifier = Modifier.weight(1f)) {
bmp?.let { if (ShareImage.save(ctx, it, label)) st.toast("Saved to Pictures/Vellum") else st.toast("Couldn't save the image") }
}
SqButton("Share", icon = Icons.Filled.Share, filled = true, modifier = Modifier.weight(1f)) {
bmp?.let { ShareImage.share(ctx, it, label, "$label (${st.bible.trCode(st.settings.tr)})") }
}
}
SectionLabel("Shape")
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ImageShape.entries.forEach { sh -> Chip(sh.label, style.shape == sh) { style = style.copy(shape = sh) } }
}
SectionLabel("Background")
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Backdrops.all.forEachIndexed { i, b -> Swatch(b, style.backdrop == i, Modifier.width(70.dp)) { style = style.copy(backdrop = i) } }
}
SectionLabel("Font")
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Fonts.all.forEachIndexed { i, f -> Chip(f.name.substringBefore(" ("), style.font == i, family = f.family) { style = style.copy(font = i) } }
}
ToggleRow("Italic", style.italic) { style = style.copy(italic = it) }
ToggleRow("Border", style.frame) { style = style.copy(frame = it) }
ToggleRow("Show translation name", style.showTranslation) { style = style.copy(showTranslation = it) }
SectionLabel("Words on the image")
BasicTextField(
value = text, onValueChange = { text = it },
modifier = Modifier.fillMaxWidth().border(1.dp, bd.ink.copy(alpha = 0.6f)).padding(12.dp),
textStyle = uiStyle(bd, 16), cursorBrush = SolidColor(bd.ink)
)
Text("Trim it down if it's long; X shows images best at 16:9.", Modifier.padding(vertical = 6.dp), color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
Spacer(Modifier.height(40.dp))
}
}
}
@@ -0,0 +1,159 @@
package space.bloggin.vellum
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
class Settings private constructor(ctx: Context) {
private val p: SharedPreferences = ctx.getSharedPreferences("vellum", Context.MODE_PRIVATE)
private fun int(key: String, def: Int) = mutableIntStateOf(p.getInt(key, def))
private fun bool(key: String, def: Boolean) = mutableStateOf(p.getBoolean(key, def))
private fun float(key: String, def: Float) = mutableFloatStateOf(p.getFloat(key, def))
private val _font = int("font", 0)
private val _size = float("size", 20f)
private val _spacing = float("spacing", 1.55f)
private val _bg = int("bg", 0)
private val _night = bool("night", false)
private val _nightBg = int("nightBg", 3)
private val _jesusBlue = bool("jesusBlue", true)
private val _italics = bool("italics", true)
private val _tr = int("tr", 0)
private val _parallel = int("parallel", -1)
private val _mode = int("mode", 0)
private val _wordStudy = bool("wordStudy", false)
private val _scrollSpeed = float("scrollSpeed", 40f)
private val _reelSeconds = int("reelSeconds", 8)
private val _ttsRate = float("ttsRate", 0.95f)
private val _notify = bool("notify", false)
private val _notifyHour = int("notifyHour", 7)
private val _notifyMinute = int("notifyMinute", 30)
private val _openAtVotd = bool("openAtVotd", true)
private val _lastItem = int("lastItem", 1)
private val _keepAwake = bool("keepAwake", true)
var font by Wrap(_font, "font")
var size by WrapF(_size, "size")
var spacing by WrapF(_spacing, "spacing")
var bg by Wrap(_bg, "bg")
var night by WrapB(_night, "night")
var nightBg by Wrap(_nightBg, "nightBg")
var jesusBlue by WrapB(_jesusBlue, "jesusBlue")
var italics by WrapB(_italics, "italics")
var tr by Wrap(_tr, "tr")
var parallel by Wrap(_parallel, "parallel")
/** 0 = scroll, 1 = reel (one verse per screen) */
var mode by Wrap(_mode, "mode")
var wordStudy by WrapB(_wordStudy, "wordStudy")
var scrollSpeed by WrapF(_scrollSpeed, "scrollSpeed")
var reelSeconds by Wrap(_reelSeconds, "reelSeconds")
var ttsRate by WrapF(_ttsRate, "ttsRate")
var notify by WrapB(_notify, "notify")
var notifyHour by Wrap(_notifyHour, "notifyHour")
var notifyMinute by Wrap(_notifyMinute, "notifyMinute")
var openAtVotd by WrapB(_openAtVotd, "openAtVotd")
var lastItem by Wrap(_lastItem, "lastItem")
var keepAwake by WrapB(_keepAwake, "keepAwake")
inner class Wrap(private val s: androidx.compose.runtime.MutableIntState, private val key: String) {
operator fun getValue(t: Any?, prop: kotlin.reflect.KProperty<*>) = s.intValue
operator fun setValue(t: Any?, prop: kotlin.reflect.KProperty<*>, v: Int) { s.intValue = v; p.edit().putInt(key, v).apply() }
}
inner class WrapF(private val s: androidx.compose.runtime.MutableFloatState, private val key: String) {
operator fun getValue(t: Any?, prop: kotlin.reflect.KProperty<*>) = s.floatValue
operator fun setValue(t: Any?, prop: kotlin.reflect.KProperty<*>, v: Float) { s.floatValue = v; p.edit().putFloat(key, v).apply() }
}
inner class WrapB(private val s: androidx.compose.runtime.MutableState<Boolean>, private val key: String) {
operator fun getValue(t: Any?, prop: kotlin.reflect.KProperty<*>) = s.value
operator fun setValue(t: Any?, prop: kotlin.reflect.KProperty<*>, v: Boolean) { s.value = v; p.edit().putBoolean(key, v).apply() }
}
/** the background actually shown, taking night mode into account */
val activeBg: Backdrop get() = Backdrops.all[if (night) nightBg else bg]
companion object {
@Volatile private var instance: Settings? = null
fun get(ctx: Context): Settings = instance ?: synchronized(this) {
instance ?: Settings(ctx.applicationContext).also { instance = it }
}
}
}
// ---------------- fonts ----------------
data class ReadingFont(val name: String, val family: FontFamily, val regular: Int, val italic: Int, val bold: Int)
object Fonts {
val all = listOf(
ReadingFont("Tinos (Times New Roman style)", fam(R.font.tinos_regular, R.font.tinos_italic, R.font.tinos_bold), R.font.tinos_regular, R.font.tinos_italic, R.font.tinos_bold),
ReadingFont("EB Garamond", fam(R.font.ebgaramond_regular, R.font.ebgaramond_italic, R.font.ebgaramond_bold), R.font.ebgaramond_regular, R.font.ebgaramond_italic, R.font.ebgaramond_bold),
ReadingFont("Crimson Pro", fam(R.font.crimsonpro_regular, R.font.crimsonpro_italic, R.font.crimsonpro_bold), R.font.crimsonpro_regular, R.font.crimsonpro_italic, R.font.crimsonpro_bold),
ReadingFont("IM Fell English", fam(R.font.imfellenglish_regular, R.font.imfellenglish_italic, R.font.imfellenglish_regular), R.font.imfellenglish_regular, R.font.imfellenglish_italic, R.font.imfellenglish_regular),
ReadingFont("Cardo", fam(R.font.cardo_regular, R.font.cardo_italic, R.font.cardo_bold), R.font.cardo_regular, R.font.cardo_italic, R.font.cardo_bold),
)
val display = FontFamily(Font(R.font.imfellenglishsc_regular))
val fell = FontFamily(Font(R.font.imfellenglish_regular), Font(R.font.imfellenglish_italic, style = FontStyle.Italic))
private fun fam(r: Int, i: Int, b: Int) = FontFamily(
Font(r, FontWeight.Normal, FontStyle.Normal),
Font(i, FontWeight.Normal, FontStyle.Italic),
Font(b, FontWeight.Bold, FontStyle.Normal),
)
}
// ---------------- backgrounds ----------------
data class Backdrop(
val name: String,
val image: Int?,
val gradient: List<Color>?,
val dark: Boolean,
val ink: Color,
val faint: Color,
val rubric: Color,
val jesus: Color,
val panel: Color,
)
object Backdrops {
private val inkBrown = Color(0xFF2B1D0E)
private val faintBrown = Color(0xFF6E5A40)
private val rubric = Color(0xFF8E1F14)
private val steel = Color(0xFF4682B4)
private val steelNight = Color(0xFF8DB4DA)
private val nightInk = Color(0xFFEBDCBC)
val all = listOf(
Backdrop("Aged vellum", R.drawable.bg_vellum, null, false, inkBrown, faintBrown, rubric, steel, Color(0xF2F3E7CF)),
Backdrop("Papyrus", R.drawable.bg_papyrus, null, false, Color(0xFF26180A), Color(0xFF5E4728), rubric, steel, Color(0xF2E9D3A6)),
Backdrop("Old parchment", R.drawable.bg_parchment, null, false, inkBrown, faintBrown, rubric, steel, Color(0xF2EBD6AE)),
Backdrop("Candlelight (night)", R.drawable.bg_candle, null, true, nightInk, Color(0xFFB09A74), Color(0xFFE08A5A), steelNight, Color(0xF2261A10)),
Backdrop("Dawn", null, listOf(Color(0xFFFBF1E2), Color(0xFFF2D9C0), Color(0xFFE8C3A4)), false, inkBrown, faintBrown, rubric, steel, Color(0xF2F8E6D2)),
Backdrop("Olive grove", null, listOf(Color(0xFFF1EFDC), Color(0xFFDCD8B4), Color(0xFFC3BF8E)), false, Color(0xFF26261A), Color(0xFF5E5C3E), rubric, steel, Color(0xF2E6E3C6)),
Backdrop("Morning sky", null, listOf(Color(0xFFF4F7FA), Color(0xFFDCE6F0), Color(0xFFBFD1E3)), false, Color(0xFF1C2430), Color(0xFF52627A), rubric, steel, Color(0xF2E4ECF4)),
Backdrop("Rose window", null, listOf(Color(0xFFFAF0F0), Color(0xFFF0D8D8), Color(0xFFE0BCC0)), false, Color(0xFF2E1A1C), Color(0xFF6E4E52), rubric, steel, Color(0xF2F3E2E2)),
Backdrop("Midnight", null, listOf(Color(0xFF1C2026), Color(0xFF12151A), Color(0xFF0A0C0F)), true, Color(0xFFDCE2EA), Color(0xFF8A94A2), Color(0xFFE07A6A), steelNight, Color(0xF21A1E24)),
Backdrop("Plain white", null, listOf(Color.White, Color.White), false, Color(0xFF111111), Color(0xFF666666), rubric, steel, Color(0xF2FFFFFF)),
Backdrop("Plain black", null, listOf(Color.Black, Color.Black), true, Color(0xFFE6E6E6), Color(0xFF8C8C8C), Color(0xFFE07A6A), steelNight, Color(0xF2111111)),
)
val nightChoices = listOf(3, 8, 10)
}
val HighlightColors = listOf(
Color.Transparent,
Color(0x66F5D547), // gold
Color(0x5590C964), // green
Color(0x5574A9DC), // blue
Color(0x55F08CB4), // pink
Color(0x60F2A54A), // orange
Color(0x55B48CE0), // violet
)
val HighlightNames = listOf("None", "Gold", "Green", "Blue", "Pink", "Orange", "Violet")
@@ -0,0 +1,143 @@
package space.bloggin.vellum
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Shader
import android.graphics.Typeface
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.FileProvider
import androidx.core.content.res.ResourcesCompat
import java.io.File
enum class ImageShape(val label: String, val w: Int, val h: Int) {
SQUARE("Square 1:1", 1080, 1080),
WIDE("Wide 16:9 (X / Twitter)", 1600, 900),
PORTRAIT("Portrait 4:5", 1080, 1350),
STORY("Story 9:16", 1080, 1920),
}
data class ImageStyle(
val backdrop: Int = 0,
val font: Int = 0,
val shape: ImageShape = ImageShape.WIDE,
val italic: Boolean = false,
val showTranslation: Boolean = true,
val frame: Boolean = true,
)
object ShareImage {
fun render(ctx: Context, text: String, reference: String, style: ImageStyle): Bitmap {
val w = style.shape.w; val h = style.shape.h
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
val bd = Backdrops.all[style.backdrop]
// background
if (bd.image != null) {
val src = BitmapFactory.decodeResource(ctx.resources, bd.image)
val scale = maxOf(w.toFloat() / src.width, h.toFloat() / src.height)
val sw = (w / scale).toInt(); val sh = (h / scale).toInt()
val sx = (src.width - sw) / 2; val sy = (src.height - sh) / 2
c.drawBitmap(src, Rect(sx, sy, sx + sw, sy + sh), Rect(0, 0, w, h), Paint(Paint.FILTER_BITMAP_FLAG))
} else {
val cols = bd.gradient!!.map { it.toArgb() }.toIntArray()
val p = Paint().apply { shader = LinearGradient(0f, 0f, w * 0.4f, h.toFloat(), cols, null, Shader.TileMode.CLAMP) }
c.drawRect(0f, 0f, w.toFloat(), h.toFloat(), p)
}
val ink = bd.ink.toArgb(); val rubric = bd.rubric.toArgb(); val faint = bd.faint.toArgb()
val margin = (minOf(w, h) * 0.09f)
if (style.frame) {
val fp = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.style = Paint.Style.STROKE; color = rubric; alpha = 150; strokeWidth = 3f }
val inset = margin * 0.45f
c.drawRect(RectF(inset, inset, w - inset, h - inset), fp)
fp.strokeWidth = 1.5f; fp.alpha = 110
c.drawRect(RectF(inset + 10, inset + 10, w - inset - 10, h - inset - 10), fp)
}
val f = Fonts.all[style.font]
val tfBody: Typeface = ResourcesCompat.getFont(ctx, if (style.italic) f.italic else f.regular) ?: Typeface.SERIF
val tfRef: Typeface = ResourcesCompat.getFont(ctx, R.font.imfellenglishsc_regular) ?: Typeface.SERIF
val quoted = "“" + text.trim() + "”"
val boxW = (w - margin * 2).toInt()
val refSize = minOf(w, h) * 0.045f
val maxTextH = h - margin * 2 - refSize * 3.2f
// fit the text: biggest size that fits
val tp = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { typeface = tfBody; color = ink }
var size = minOf(w, h) * (if (h > w * 1.5f) 0.11f else 0.085f)
var layout: StaticLayout
while (true) {
tp.textSize = size
layout = StaticLayout.Builder.obtain(quoted, 0, quoted.length, tp, boxW)
.setAlignment(Layout.Alignment.ALIGN_CENTER).setLineSpacing(0f, 1.18f).setIncludePad(false).build()
if (layout.height <= maxTextH || size < 18f) break
size *= 0.94f
}
val total = layout.height + refSize * 2.6f
var y = (h - total) / 2f
c.save(); c.translate(margin, y); layout.draw(c); c.restore()
y += layout.height + refSize * 1.0f
// ornament line
val op = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = rubric; strokeWidth = 2f; alpha = 180 }
val cx = w / 2f
c.drawLine(cx - refSize * 2.2f, y, cx - refSize * 0.5f, y, op)
c.drawLine(cx + refSize * 0.5f, y, cx + refSize * 2.2f, y, op)
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = rubric }
c.drawCircle(cx, y, 4.5f, dot)
y += refSize * 1.5f
val rp = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { typeface = tfRef; color = rubric; textSize = refSize; textAlign = Paint.Align.CENTER; letterSpacing = 0.06f }
c.drawText(reference, cx, y, rp)
return bmp
}
private fun fileName(reference: String) =
"vellum-" + reference.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-') + ".png"
fun share(ctx: Context, bmp: Bitmap, reference: String, caption: String) {
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val f = File(dir, fileName(reference))
f.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
val uri = FileProvider.getUriForFile(ctx, ctx.packageName + ".files", f)
val i = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TEXT, caption)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
ctx.startActivity(Intent.createChooser(i, "Share verse image").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
/** Saves into Pictures/Vellum. Returns true on success. */
fun save(ctx: Context, bmp: Bitmap, reference: String): Boolean = try {
val name = fileName(reference)
if (Build.VERSION.SDK_INT >= 29) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Vellum")
}
val uri = ctx.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
ctx.contentResolver.openOutputStream(uri)!!.use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
} else {
@Suppress("DEPRECATION")
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "Vellum").apply { mkdirs() }
File(dir, name).outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
}
true
} catch (e: Exception) { false }
}
@@ -0,0 +1,373 @@
package space.bloggin.vellum
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Square bottom sheet over a dim scrim. */
@Composable
fun BottomSheet(st: AppState, title: String, maxFraction: Float = 0.78f, content: @Composable ColumnScope.() -> Unit) {
val bd = LocalBackdrop.current
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { st.closeSheet() }
) {
BoxWithConstraints(Modifier.align(Alignment.BottomCenter).fillMaxWidth()) {
val maxH = this.maxHeight
Column(
Modifier
.align(Alignment.BottomCenter)
.widthIn(max = 760.dp)
.fillMaxWidth()
.heightIn(max = maxH * maxFraction)
.background(bd.panel.copy(alpha = 1f))
.border(1.dp, bd.faint.copy(alpha = 0.5f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.navigationBarsPadding()
.imePadding()
) {
Row(Modifier.fillMaxWidth().padding(start = 18.dp, end = 4.dp, top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
if (st.sheetStack.isNotEmpty()) IconBtn(Icons.Filled.ArrowBack, "Back") { st.back() }
Text(title, Modifier.weight(1f), color = bd.rubric, fontFamily = Fonts.display, fontSize = 20.sp, maxLines = 1)
IconBtn(Icons.Filled.Close, "Close") { st.closeSheet() }
}
Rule()
content()
}
}
}
}
@Composable
fun SheetHost(st: AppState, sheet: Sheet) {
when (sheet) {
is Sheet.StrongsWords -> StrongsWordsSheet(st, sheet.ref)
is Sheet.StrongsDef -> StrongsDefSheet(st, sheet.id, sheet.word)
is Sheet.Xrefs -> XrefSheet(st, sheet.ref)
is Sheet.Compare -> CompareSheet(st, sheet.refs)
is Sheet.NoteEdit -> NoteSheet(st, sheet.ref)
Sheet.ReadAloud -> ReadAloudSheet(st)
Sheet.AutoScroll -> ReadAloudSheet(st)
Sheet.Translations -> TranslationSheet(st)
}
}
@Composable
fun StrongsWordsSheet(st: AppState, ref: Ref) {
val bd = LocalBackdrop.current
val markup = st.bible.verse(0, ref.book, ref.chapter, ref.verse) ?: ""
val words = remember(ref) { Markup.words(markup) }
BottomSheet(st, "Original words · " + st.bible.refLabel(ref)) {
Text("From the King James text, tagged with Strong's numbers. Tap a word for the Hebrew or Greek behind it.",
Modifier.padding(horizontal = 18.dp, vertical = 10.dp), color = bd.faint, fontFamily = UiFont, fontSize = 13.sp)
LazyColumn {
items(words) { (w, id) ->
val e = remember(id) { st.bible.strongs(id) }
Row(
Modifier.fillMaxWidth().clickable { st.showSheet(Sheet.StrongsDef(id, w), stack = true) }.padding(horizontal = 18.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(w, color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 19.sp)
if (e != null) Text(e.def.trim().take(90), color = bd.faint, fontFamily = UiFont, fontSize = 13.sp, maxLines = 1)
}
Column(horizontalAlignment = Alignment.End) {
if (e != null) Text(e.lemma, color = bd.ink, fontSize = 20.sp)
Text(id, color = bd.rubric, fontFamily = UiFont, fontSize = 12.sp)
}
}
Rule(Modifier.padding(horizontal = 18.dp), alpha = 0.2f)
}
}
}
}
@Composable
fun StrongsDefSheet(st: AppState, id: String, word: String) {
val bd = LocalBackdrop.current
val e = remember(id) { st.bible.strongs(id) }
var uses by remember(id) { mutableStateOf<List<SearchHit>?>(null) }
var count by remember(id) { mutableIntStateOf(0) }
LaunchedEffect(id) {
withContext(Dispatchers.IO) { count = st.bible.strongsCount(id); uses = st.bible.strongsUses(id, 150) }
}
BottomSheet(st, "$id · “$word”") {
LazyColumn(Modifier.fillMaxWidth()) {
item {
Column(Modifier.padding(18.dp)) {
if (e == null) {
Text("No dictionary entry for $id.", color = bd.ink, fontFamily = UiFont)
} else {
Text(e.lemma, color = bd.ink, fontSize = 40.sp)
Text(listOf(e.xlit, e.pron).filter { it.isNotBlank() }.joinToString(" · "), color = bd.faint, fontFamily = UiFont, fontSize = 15.sp, fontStyle = FontStyle.Italic)
Text(if (id.startsWith("H")) "Hebrew" else "Greek", color = bd.rubric, fontFamily = Fonts.display, fontSize = 15.sp, modifier = Modifier.padding(top = 4.dp))
SectionLabel("Meaning")
Text(e.def.trim().replaceFirstChar { it.uppercase() }, color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 18.sp, lineHeight = 26.sp)
if (e.kjv.isNotBlank()) {
SectionLabel("Translated in the KJV as")
Text(e.kjv.trim(), color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 17.sp, lineHeight = 24.sp)
}
if (e.deriv.isNotBlank()) {
SectionLabel("Root")
// tappable related numbers
val parts = Regex("([HG]\\d+)").findAll(e.deriv).map { it.value }.distinct().toList()
Text(e.deriv.trim(), color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 16.sp, lineHeight = 23.sp)
if (parts.isNotEmpty()) {
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
parts.take(4).forEach { p -> Chip(p, false) { st.showSheet(Sheet.StrongsDef(p, p), stack = true) } }
}
}
}
}
SectionLabel(if (uses == null) "Finding verses…" else "Used in $count KJV verses")
}
}
items(uses ?: emptyList()) { h ->
Column(Modifier.fillMaxWidth().clickable { st.goTo(h.ref) }.padding(horizontal = 18.dp, vertical = 8.dp)) {
Text(st.bible.refLabel(h.ref), color = bd.rubric, fontFamily = UiFont, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text(h.text, color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 16.sp, maxLines = 3)
}
}
item { Spacer(Modifier.height(20.dp)) }
}
}
}
@Composable
fun XrefSheet(st: AppState, ref: Ref) {
val bd = LocalBackdrop.current
val list = remember(ref) { st.bible.xrefs(ref.book, ref.chapter, ref.verse) }
BottomSheet(st, "Cross references · " + st.bible.refLabel(ref)) {
if (list.isEmpty()) Text("No cross references for this verse.", Modifier.padding(18.dp), color = bd.faint, fontFamily = UiFont)
LazyColumn {
items(list) { x ->
val t = x.target
Column(Modifier.fillMaxWidth().clickable { st.goTo(Ref(t.book, t.chapter, t.verse)) }.padding(horizontal = 18.dp, vertical = 9.dp)) {
Text(st.bible.refLabel(t), color = bd.rubric, fontFamily = UiFont, fontSize = 14.sp, fontWeight = FontWeight.Bold)
Text(st.bible.plainRange(st.settings.tr, t), color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 17.sp, lineHeight = 24.sp, maxLines = 5)
}
}
item {
Text("Cross references from openbible.info (CC BY), sorted by how many readers linked them.",
Modifier.padding(18.dp), color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
}
}
}
}
@Composable
fun CompareSheet(st: AppState, refs: List<Ref>) {
val bd = LocalBackdrop.current
BottomSheet(st, "Compare · " + st.labelFor(refs), maxFraction = 0.88f) {
LazyColumn {
items(st.bible.translations) { t ->
val text = refs.joinToString(" ") { st.bible.plainVerse(t.id, it.book, it.chapter, it.verse) }.trim()
Column(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(t.code, color = bd.rubric, fontFamily = UiFont, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Text(" " + t.name, color = bd.faint, fontFamily = UiFont, fontSize = 12.sp, modifier = Modifier.weight(1f))
IconBtn(Icons.Filled.ContentCopy, "Copy", size = 18) { st.copy(text, st.labelFor(refs)) }
}
Text(text.ifBlank { "(not in this translation)" }, color = bd.ink, fontFamily = Fonts.all[st.settings.font].family, fontSize = 18.sp, lineHeight = 26.sp)
if (t.code == "DRC" && refs.first().book == 19) {
Text("The Douay-Rheims numbers the Psalms differently, so this may be a neighbouring psalm.", color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
}
}
Rule(Modifier.padding(horizontal = 18.dp), alpha = 0.2f)
}
}
}
}
@Composable
fun NoteSheet(st: AppState, ref: Ref) {
val bd = LocalBackdrop.current
var text by remember(ref) { mutableStateOf(st.user.note(ref)?.text ?: "") }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { try { focus.requestFocus() } catch (_: Exception) {} }
BottomSheet(st, "Note · " + st.bible.refLabel(ref)) {
Text(st.bible.plainVerse(st.settings.tr, ref.book, ref.chapter, ref.verse), Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = bd.faint, fontFamily = Fonts.all[st.settings.font].family, fontStyle = FontStyle.Italic, fontSize = 16.sp, maxLines = 4)
BasicTextField(
value = text, onValueChange = { text = it },
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp).heightIn(min = 140.dp).border(1.dp, bd.ink.copy(alpha = 0.6f)).padding(12.dp).focusRequester(focus),
textStyle = uiStyle(bd, 17),
cursorBrush = SolidColor(bd.ink),
decorationBox = { inner ->
if (text.isEmpty()) Text("Write your thoughts…", color = bd.faint, fontFamily = UiFont, fontSize = 17.sp)
inner()
}
)
Row(Modifier.fillMaxWidth().padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (st.user.note(ref) != null) SqButton("Delete", icon = Icons.Filled.Delete) { st.user.setNote(ref, ""); st.closeSheet() }
Spacer(Modifier.weight(1f))
SqButton("Save", icon = Icons.Filled.Check, filled = true) { st.user.setNote(ref, text); st.selection.clear(); st.closeSheet() }
}
}
}
@Composable
fun ReadAloudSheet(st: AppState) {
val bd = LocalBackdrop.current
val s = st.settings
val speaking by st.speech.speaking
BottomSheet(st, "Listen & auto-scroll") {
Column(Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp, vertical = 8.dp)) {
SectionLabel("Read aloud")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (!speaking) SqButton("Read from ${st.bible.book(st.currentBook).abbr} ${st.currentChapter}:${st.currentVerse}", icon = Icons.Filled.PlayArrow, filled = true) {
st.speech.start(Ref(st.currentBook, st.currentChapter, st.currentVerse)); st.closeSheet()
} else SqButton("Stop reading", icon = Icons.Filled.Stop, filled = true) { st.speech.stop() }
SqButton("From chapter start", icon = Icons.Filled.Replay) { st.speech.start(Ref(st.currentBook, st.currentChapter, 1)); st.closeSheet() }
}
SliderRow("Voice speed", s.ttsRate, 0.5f..1.6f, "%.2fx".format(s.ttsRate)) { s.ttsRate = it }
SectionLabel("Sleep timer")
val sleepAt = st.speech.sleepAt.longValue
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
for (m in listOf(0, 10, 15, 30, 45, 60)) {
val sel = if (m == 0) sleepAt == 0L else false
Chip(if (m == 0) "Off" else "$m", sel, Modifier.weight(1f)) { st.speech.setSleep(m) }
}
}
if (sleepAt > 0) Text("Stops at " + java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(sleepAt)),
color = bd.faint, fontFamily = UiFont, fontSize = 13.sp, modifier = Modifier.padding(top = 6.dp))
Text("Uses your phone's built-in voice. To change the voice, open Android Settings → Text-to-speech.",
color = bd.faint, fontFamily = UiFont, fontSize = 12.sp, modifier = Modifier.padding(top = 8.dp))
SectionLabel(if (s.mode == 0) "Auto-scroll" else "Auto-advance")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SqButton(if (st.autoScroll) "Stop" else "Start", icon = if (st.autoScroll) Icons.Filled.Pause else Icons.Filled.KeyboardDoubleArrowDown, filled = true) {
st.autoScroll = !st.autoScroll; st.closeSheet()
}
}
if (s.mode == 0) SliderRow("Scroll speed", s.scrollSpeed, 5f..200f, "${s.scrollSpeed.toInt()}") { s.scrollSpeed = it }
else SliderRow("Seconds per verse", s.reelSeconds.toFloat(), 2f..30f, "${s.reelSeconds}s", steps = 27) { s.reelSeconds = it.toInt() }
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
fun TranslationSheet(st: AppState) {
val bd = LocalBackdrop.current
val s = st.settings
BottomSheet(st, "Translation") {
Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 12.dp)) {
for (t in st.bible.translations) {
Row(Modifier.fillMaxWidth().clickable { s.tr = t.id; if (s.parallel == t.id) s.parallel = -1; st.closeSheet() }.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(t.code, Modifier.width(56.dp), color = bd.rubric, fontFamily = UiFont, fontWeight = FontWeight.Bold)
Text(t.name, Modifier.weight(1f), color = bd.ink, fontFamily = UiFont, fontSize = 16.sp)
if (s.tr == t.id) Icon(Icons.Filled.Check, null, tint = bd.ink)
}
}
Rule()
SectionLabel("Side by side", Modifier.padding(horizontal = 18.dp))
Row(Modifier.fillMaxWidth().padding(horizontal = 18.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Chip("Off", s.parallel < 0) { s.parallel = -1 }
for (t in st.bible.translations) if (t.id != s.tr) Chip(t.code, s.parallel == t.id) { s.parallel = t.id }
}
Text("Shows a second translation next to each verse. Works best on the big inside screen.",
Modifier.padding(horizontal = 18.dp, vertical = 8.dp), color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
if (s.tr != 0) Text("Words of Jesus and Strong's word study come from the KJV and WEB markup; other translations show plain text.",
Modifier.padding(horizontal = 18.dp), color = bd.faint, fontFamily = UiFont, fontSize = 12.sp)
}
}
}
// ------------------------------------------------------------------ drawer
@Composable
fun Drawer(st: AppState) {
val bd = LocalBackdrop.current
AnimatedVisibility(st.drawerOpen, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)).clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { st.drawerOpen = false })
}
AnimatedVisibility(st.drawerOpen, enter = slideInHorizontally { -it }, exit = slideOutHorizontally { -it }) {
Column(
Modifier.fillMaxHeight().width(300.dp).background(bd.panel.copy(alpha = 1f)).border(1.dp, bd.faint.copy(alpha = 0.4f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.statusBarsPadding().navigationBarsPadding().verticalScroll(rememberScrollState())
) {
Text("Vellum", Modifier.padding(start = 20.dp, top = 20.dp), color = bd.ink, fontFamily = Fonts.display, fontSize = 34.sp)
Text("The Holy Bible", Modifier.padding(start = 20.dp, bottom = 12.dp), color = bd.faint, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 16.sp)
Rule()
DrawerItem(Icons.Filled.WbSunny, "Verse of the Day") { if (st.settings.mode != 0) st.settings.mode = 0; st.goTop() }
DrawerItem(Icons.Filled.MenuBook, "Go to book & chapter") { st.open(Screen.GoTo) }
DrawerItem(Icons.Filled.Search, "Search") { st.open(Screen.Search) }
DrawerItem(Icons.Filled.EventNote, "Reading plans") { st.open(Screen.PlansScreen) }
DrawerItem(Icons.Filled.Favorite, "Verses by topic") { st.open(Screen.TopicsScreen) }
DrawerItem(Icons.Filled.Bookmarks, "Bookmarks, highlights & notes") { st.open(Screen.Library()) }
Rule(Modifier.padding(vertical = 6.dp))
DrawerItem(if (st.settings.mode == 0) Icons.Filled.ViewDay else Icons.Filled.ViewStream,
if (st.settings.mode == 0) "Switch to Reel (one verse a screen)" else "Switch to Scroll (continuous)") {
st.settings.mode = 1 - st.settings.mode; st.drawerOpen = false
}
DrawerItem(Icons.Filled.Headphones, "Read aloud & auto-scroll") { st.drawerOpen = false; st.showSheet(Sheet.ReadAloud) }
DrawerItem(if (st.settings.night) Icons.Filled.LightMode else Icons.Filled.DarkMode, if (st.settings.night) "Day mode" else "Night mode") {
st.settings.night = !st.settings.night
}
DrawerItem(Icons.Filled.TextFields, "Font, size & background") { st.open(Screen.Appearance) }
DrawerItem(Icons.Filled.Settings, "Settings") { st.open(Screen.SettingsScreen) }
DrawerItem(Icons.Filled.Info, "About & credits") { st.open(Screen.About) }
}
}
}
@Composable
fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
val bd = LocalBackdrop.current
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = bd.ink, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(16.dp))
Text(label, color = bd.ink, fontFamily = UiFont, fontSize = 16.sp)
}
}
/** Bold the search words inside a result. */
fun highlightMatches(text: String, query: String, color: Color) = buildAnnotatedString {
val words = query.trim('"').split(Regex("\\s+")).filter { it.length >= 2 }.map { it.lowercase() }
if (words.isEmpty()) { append(text); return@buildAnnotatedString }
val lower = text.lowercase()
var i = 0
while (i < text.length) {
var hitLen = 0
for (w in words) if (lower.startsWith(w, i)) hitLen = maxOf(hitLen, w.length)
if (hitLen > 0) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = color)) { append(text.substring(i, i + hitLen)) }
i += hitLen
} else { append(text[i]); i++ }
}
}
@@ -0,0 +1,87 @@
package space.bloggin.vellum
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import java.util.Locale
/** Reads verses aloud one at a time with the phone's own text-to-speech voice. */
class Speech(ctx: Context, private val bible: Bible, private val settings: Settings) {
val current = mutableStateOf<Ref?>(null)
val speaking = mutableStateOf(false)
/** System.currentTimeMillis() when the sleep timer fires, 0 = off */
val sleepAt = mutableLongStateOf(0L)
private val main = Handler(Looper.getMainLooper())
private var ready = false
private var pendingStart: Ref? = null
private var announcedChapter = -1
private val sleepRunnable = Runnable { stop(); sleepAt.longValue = 0L }
private val tts: TextToSpeech = TextToSpeech(ctx.applicationContext) { status ->
ready = status == TextToSpeech.SUCCESS
if (ready) {
tts.language = Locale.US
pendingStart?.let { pendingStart = null; start(it) }
}
}
init {
tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(id: String?) {}
override fun onDone(id: String?) { main.post { if (speaking.value) advance() } }
@Deprecated("Deprecated in Java")
override fun onError(id: String?) { main.post { stop() } }
})
}
fun start(from: Ref) {
if (!ready) { pendingStart = from; speaking.value = true; current.value = from; return }
speaking.value = true
announcedChapter = -1
speak(Ref(from.book, from.chapter, maxOf(1, from.verse)))
}
private fun speak(r: Ref) {
current.value = r
tts.setSpeechRate(settings.ttsRate)
val chapKey = r.book * 1000 + r.chapter
val text = bible.plainVerse(settings.tr, r.book, r.chapter, r.verse)
val intro = if (r.verse == 1 && announcedChapter != chapKey) {
val name = bible.book(r.book).name
if (r.book == 19) "Psalm ${r.chapter}. " else "$name, chapter ${r.chapter}. "
} else ""
announcedChapter = chapKey
tts.speak(intro + text, TextToSpeech.QUEUE_FLUSH, null, "v${r.key()}")
}
private fun advance() {
val r = current.value ?: return stop()
val next = when {
r.verse < bible.verseCount(r.book, r.chapter) -> Ref(r.book, r.chapter, r.verse + 1)
else -> bible.nextChapter(r.book, r.chapter)?.let { Ref(it.first, it.second, 1) }
}
if (next == null) stop() else speak(next)
}
fun stop() {
speaking.value = false
tts.stop()
}
fun setSleep(minutes: Int) {
main.removeCallbacks(sleepRunnable)
if (minutes <= 0) { sleepAt.longValue = 0L; return }
sleepAt.longValue = System.currentTimeMillis() + minutes * 60_000L
main.postDelayed(sleepRunnable, minutes * 60_000L)
}
fun shutdown() {
main.removeCallbacks(sleepRunnable)
tts.stop(); tts.shutdown()
}
}