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) } } }