Files
SimpleMercies/app/src/main/java/space/bloggin/mercies/Tabs.kt
T

305 lines
17 KiB
Kotlin

package space.bloggin.mercies
import android.Manifest
import android.app.TimePickerDialog
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.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.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
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 java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
// ------------------------------------------------------------------ kindness
@Composable
fun KindCard(st: AppState, big: Boolean) {
val l = LocalLook.current
val rev = st.store.rev.intValue
val k = remember(rev) { st.store.kindToday() }
val done = remember(rev) { st.store.kindDoneCount() }
Panel(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("TODAY'S KINDNESS", color = l.accent, fontFamily = Fonts.fellSc, fontSize = 15.sp, letterSpacing = 1.5.sp)
Spacer(Modifier.height(10.dp))
Text(Kindness.all[k.idx], color = l.ink, fontFamily = Fonts.body, fontSize = if (big) 26.sp else 21.sp,
lineHeight = if (big) 34.sp else 28.sp, textAlign = TextAlign.Center)
Spacer(Modifier.height(14.dp))
when (k.status) {
1 -> {
Text("Done. Thank you.", color = l.leaf, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 20.sp)
Small(if (done == 1) "Your first kindness." else "$done kindnesses so far.", center = true)
Row(Modifier.padding(top = 10.dp)) { SqButton("Undo", icon = Icons.Filled.Undo) { st.store.markKind(0) } }
}
2 -> {
Text("Not today, and that's all right.", color = l.faint, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 19.sp, textAlign = TextAlign.Center)
Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Try another", icon = Icons.Filled.Refresh) { st.store.anotherKind() }
SqButton("Undo", icon = Icons.Filled.Undo) { st.store.markKind(0) }
}
}
else -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Done", icon = Icons.Filled.Check, filled = true) { st.store.markKind(1) }
SqButton("Another", icon = Icons.Filled.Refresh) { st.store.anotherKind() }
SqButton("Not today") { st.store.markKind(2) }
}
}
}
}
}
@Composable
fun KindMini(st: AppState) = KindCard(st, big = false)
@Composable
fun KindTab(st: AppState) {
val l = LocalLook.current
val rev = st.store.rev.intValue
val history = remember(rev) { st.store.kindDone() }
LazyColumn(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
item { Header(st, "Kindness", "One small good thing a day") }
item {
Column(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp)) {
Spacer(Modifier.height(10.dp))
KindCard(st, big = true)
Small("No streaks here. Skipping a day costs nothing. Every kindness you mark done is kept below.", Modifier.padding(top = 10.dp), center = true)
Label(if (history.isEmpty()) "Kindnesses done" else "Kindnesses done · ${history.size}")
if (history.isEmpty()) Small("They'll gather here.")
}
}
items(history) { k ->
Row(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp, vertical = 6.dp)) {
Text(k.day.format(DateTimeFormatter.ofPattern("MMM d")), Modifier.width(64.dp), color = l.accent, fontFamily = Fonts.fellSc, fontSize = 15.sp)
Text(Kindness.all[k.idx], color = l.ink, fontFamily = Fonts.body, fontSize = 17.sp)
}
}
item { Spacer(Modifier.height(30.dp)) }
}
}
// ------------------------------------------------------------------ prayers
private val pDate = DateTimeFormatter.ofPattern("MMM d, yyyy")
private fun created(ms: Long) = Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()).toLocalDate()
fun prayerMessage(p: Prayer): String {
val who = p.forWho.trim()
val head = if (who.isBlank()) "A prayer for you:" else "A prayer for you, $who:"
return "$head\n\n${p.text.trim()}\n\nSent with Simple Mercies"
}
@Composable
fun PrayersTab(st: AppState) {
val l = LocalLook.current
val rev = st.store.rev.intValue
val open = remember(rev) { st.store.prayers(false) }
val answered = remember(rev) { st.store.prayers(true) }
var showAnswered by remember { mutableStateOf(false) }
LazyColumn(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
item { Header(st, "Prayers", "Write them down. Send them on.") }
item {
Column(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp, vertical = 10.dp)) {
SqButton("Write a prayer", icon = Icons.Filled.Add, filled = true, modifier = Modifier.fillMaxWidth()) { st.open(Over.EditPrayer(null)) }
if (open.isEmpty()) Small("Pray for someone by name, then send it to them with one tap so they know they're being held up.", Modifier.padding(top = 14.dp), center = true)
}
}
items(open, key = { it.id }) { p -> PrayerCard(st, p) }
if (answered.isNotEmpty()) {
item {
Row(Modifier.widthIn(max = 640.dp).fillMaxWidth().clickable { showAnswered = !showAnswered }.padding(horizontal = 18.dp), verticalAlignment = Alignment.CenterVertically) {
Label("Answered · ${answered.size}", Modifier.weight(1f))
Icon(if (showAnswered) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null, tint = l.ink)
}
}
if (showAnswered) items(answered, key = { it.id }) { p -> PrayerCard(st, p) }
}
item { Spacer(Modifier.height(30.dp)) }
}
}
@Composable
fun PrayerCard(st: AppState, p: Prayer) {
val l = LocalLook.current
val ctx = LocalContext.current
Panel(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp, vertical = 6.dp)) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (p.forWho.isBlank()) "A prayer" else "For ${p.forWho}", Modifier.weight(1f), color = l.accent, fontFamily = Fonts.fellSc, fontSize = 20.sp)
Text(created(p.created).format(pDate), color = l.faint, fontFamily = Fonts.ui, fontSize = 12.sp)
}
Spacer(Modifier.height(6.dp))
Text(p.text, color = l.ink, fontFamily = Fonts.body, fontSize = st.prefs.size.sp, lineHeight = (st.prefs.size * 1.35f).sp)
if (p.answered) {
Spacer(Modifier.height(8.dp))
Text("Answered " + (p.answeredOn?.format(pDate) ?: "") + if (p.answerNote.isNotBlank()) ": ${p.answerNote}" else "",
color = l.leaf, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 17.sp)
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SqButton("Send", icon = Icons.Filled.Send, filled = true) {
st.sendText(prayerMessage(p), "A prayer for you", Card(if (p.forWho.isBlank()) "A prayer for you" else "A prayer for ${p.forWho}", p.text, "Simple Mercies"))
}
SqButton("Picture", icon = Icons.Filled.Image) {
st.open(Over.ShareCard(Card(if (p.forWho.isBlank()) "A prayer for you" else "A prayer for ${p.forWho}", p.text, "Simple Mercies"), ""))
}
}
Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (!p.answered) SqButton("Answered", icon = Icons.Filled.Check) { st.open(Over.Answer(p.id)) }
else SqButton("Still praying", icon = Icons.Filled.Undo) { st.store.setAnswered(p.id, false) }
SqButton("Edit", icon = Icons.Filled.Edit) { st.open(Over.EditPrayer(p.id)) }
}
}
}
}
@Composable
fun PrayerEditor(st: AppState, id: Long?) {
val existing = remember(id) { id?.let { st.store.prayer(it) } }
var who by remember { mutableStateOf(existing?.forWho ?: "") }
var text by remember { mutableStateOf(existing?.text ?: "") }
val ctx = LocalContext.current
Overlay(st, if (id == null) "New prayer" else "Edit prayer") {
Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp)) {
Label("Who is it for?")
Field(who, { who = it }, "A name, or leave it blank", Modifier.fillMaxWidth(), single = true)
Label("The prayer")
Field(text, { text = it }, "Lord, please…", Modifier.fillMaxWidth(), minHeight = 200.dp)
Row(Modifier.fillMaxWidth().padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (id != null) SqButton("Delete", icon = Icons.Filled.Delete) { st.store.deletePrayer(id); st.back() }
Spacer(Modifier.weight(1f))
SqButton("Save & send", icon = Icons.Filled.Send, enabled = text.isNotBlank()) {
st.store.savePrayer(id, who, text); st.back()
st.sendText(prayerMessage(Prayer(0, who, text, 0, false, null, "")), "A prayer for you",
Card(if (who.isBlank()) "A prayer for you" else "A prayer for ${who.trim()}", text.trim(), "Simple Mercies"))
}
SqButton("Save", icon = Icons.Filled.Check, filled = true, enabled = text.isNotBlank()) { st.store.savePrayer(id, who, text); st.back() }
}
}
}
}
@Composable
fun AnswerSheet(st: AppState, id: Long) {
var note by remember { mutableStateOf("") }
val p = remember(id) { st.store.prayer(id) }
Overlay(st, "Answered") {
Column(Modifier.padding(16.dp)) {
p?.let { Text(it.text, color = LocalLook.current.faint, fontFamily = Fonts.body, fontStyle = FontStyle.Italic, fontSize = 17.sp, maxLines = 4) }
Label("How was it answered? (optional)")
Field(note, { note = it }, "A few words to remember it by", Modifier.fillMaxWidth(), minHeight = 100.dp)
Row(Modifier.fillMaxWidth().padding(top = 14.dp)) {
Spacer(Modifier.weight(1f))
SqButton("Mark answered", icon = Icons.Filled.Check, filled = true) { st.store.setAnswered(id, true, note); st.back() }
}
}
}
}
// ------------------------------------------------------------------ settings & about
@Composable
fun SettingsScreen(st: AppState) {
val l = LocalLook.current
val ctx = LocalContext.current
val p = st.prefs
var pendingMorning by remember { mutableStateOf(false) }
val ask = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { ok ->
if (ok) { if (pendingMorning) p.morn = true else p.eve = true } else st.toast("Notifications are off for Simple Mercies in Android settings")
Daily.schedule(ctx)
}
fun needPerm() = Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
fun time(h: Int, m: Int) = "%d:%02d %s".format(if (h % 12 == 0) 12 else h % 12, m, if (h < 12) "AM" else "PM")
Overlay(st, "Settings") {
Column(Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) {
Label("Look")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (i in 0..2) Swatch(Looks.all[i], !p.night && p.look == i, Modifier.weight(1f)) { p.look = i; p.night = false }
Swatch(Looks.all[3], p.night, Modifier.weight(1f)) { p.night = !p.night }
}
SliderRow("Text size", p.size, 15f..30f, "${p.size.toInt()}") { p.size = it }
Label("Reminders")
ToggleRow("Evening: add to the jar", p.eve, "At ${time(p.eveH, p.eveM)}, a gentle “What was good today?”") { on ->
if (on && needPerm()) { pendingMorning = false; ask.launch(Manifest.permission.POST_NOTIFICATIONS) } else { p.eve = on; Daily.schedule(ctx) }
}
SqButton("Change evening time", icon = Icons.Filled.Schedule) {
TimePickerDialog(ctx, { _, h, m -> p.eveH = h; p.eveM = m; Daily.schedule(ctx) }, p.eveH, p.eveM, false).show()
}
Spacer(Modifier.height(8.dp))
ToggleRow("Morning: today's kindness", p.morn, "At ${time(p.mornH, p.mornM)}") { on ->
if (on && needPerm()) { pendingMorning = true; ask.launch(Manifest.permission.POST_NOTIFICATIONS) } else { p.morn = on; Daily.schedule(ctx) }
}
SqButton("Change morning time", icon = Icons.Filled.Schedule) {
TimePickerDialog(ctx, { _, h, m -> p.mornH = h; p.mornM = m; Daily.schedule(ctx) }, p.mornH, p.mornM, false).show()
}
Label("The jar")
ToggleRow("Shake to pull a mercy", p.shake, "Turn off if it goes off in your pocket") { p.shake = it }
Label("Sending to X (Twitter)")
ToggleRow("I have a blue checkmark on X", p.xBlue,
"Without one, X stops a post at ${XLimit.MAX} characters. Leave this off and Simple Mercies will warn you before sending something longer.") { p.xBlue = it }
Label("Your words")
Small("Everything you write stays on this phone. Make a copy now and then so it's never lost.")
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Export a copy", icon = Icons.Filled.IosShare) { ShareKit.shareText(ctx, st.store.exportText(), "Simple Mercies backup") }
SqButton("About", icon = Icons.Filled.Info) { st.open(Over.About) }
}
Small("There's also a home screen widget showing today's kindness: long-press your home screen, tap Widgets, look for Simple Mercies.", Modifier.padding(top = 14.dp))
Spacer(Modifier.height(40.dp))
}
}
}
@Composable
fun Swatch(look: Look, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val l = LocalLook.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) l.accent else l.ink.copy(alpha = 0.5f))) {
LookFill(look)
Text("Aa", Modifier.align(Alignment.Center), color = look.ink, fontFamily = Fonts.fell, fontSize = 22.sp)
}
Text(look.name, color = l.ink, fontFamily = Fonts.ui, fontSize = 11.sp, maxLines = 1)
}
}
@Composable
fun AboutScreen(st: AppState) {
val l = LocalLook.current
Overlay(st, "About") {
Column(Modifier.verticalScroll(rememberScrollState()).padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
JarDrawing(12, Modifier.size(150.dp))
Heading("Simple Mercies", size = 34)
Text("Notice the good. Do some good.", color = l.faint, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 18.sp)
Sprig(Modifier.padding(vertical = 14.dp))
Text("A gratitude jar, a daily kindness, a place to write prayers and send them to the people you're praying for, and a daily notepad for thoughts, to-dos and dreams.\n\n" +
"No account. No ads. No tracking. No internet permission. Everything stays on your phone.\n\n" +
"Fonts: IM Fell English and EB Garamond (SIL Open Font License). The linen and pressed-flower artwork was made for this app.\n\nVersion 1.2",
color = l.ink, fontFamily = Fonts.body, fontSize = 18.sp, lineHeight = 26.sp, textAlign = TextAlign.Center)
}
}
}