305 lines
17 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|