diff --git a/app/src/main/java/space/bloggin/vellum/Screens.kt b/app/src/main/java/space/bloggin/vellum/Screens.kt new file mode 100644 index 0000000..32519f3 --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Screens.kt @@ -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(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>(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(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)) + } + } +} diff --git a/app/src/main/java/space/bloggin/vellum/Settings.kt b/app/src/main/java/space/bloggin/vellum/Settings.kt new file mode 100644 index 0000000..9258a39 --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Settings.kt @@ -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, 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?, + 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") diff --git a/app/src/main/java/space/bloggin/vellum/ShareImage.kt b/app/src/main/java/space/bloggin/vellum/ShareImage.kt new file mode 100644 index 0000000..2a09bd8 --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/ShareImage.kt @@ -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 } +} diff --git a/app/src/main/java/space/bloggin/vellum/Sheets.kt b/app/src/main/java/space/bloggin/vellum/Sheets.kt new file mode 100644 index 0000000..3721917 --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Sheets.kt @@ -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?>(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) { + 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++ } + } +} diff --git a/app/src/main/java/space/bloggin/vellum/Speech.kt b/app/src/main/java/space/bloggin/vellum/Speech.kt new file mode 100644 index 0000000..ed6fead --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Speech.kt @@ -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(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() + } +}