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