From 86c37a8722fd6156fcf351f6eff752ee3e2171d3 Mon Sep 17 00:00:00 2001 From: weishaupt <1+weishaupt@noreply.localhost> Date: Mon, 19 Oct 2026 02:23:06 +0000 Subject: [PATCH] Add app/src/main/java/space/bloggin/mercies --- .../main/java/space/bloggin/mercies/Store.kt | 127 ++++++++ .../main/java/space/bloggin/mercies/Tabs.kt | 297 ++++++++++++++++++ .../main/java/space/bloggin/mercies/Theme.kt | 203 ++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 app/src/main/java/space/bloggin/mercies/Store.kt create mode 100644 app/src/main/java/space/bloggin/mercies/Tabs.kt create mode 100644 app/src/main/java/space/bloggin/mercies/Theme.kt diff --git a/app/src/main/java/space/bloggin/mercies/Store.kt b/app/src/main/java/space/bloggin/mercies/Store.kt new file mode 100644 index 0000000..4ccc1f1 --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Store.kt @@ -0,0 +1,127 @@ +package space.bloggin.mercies + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.compose.runtime.mutableIntStateOf +import java.time.LocalDate + +data class JarNote(val id: Long, val day: LocalDate, val text: String, val created: Long) +data class Prayer(val id: Long, val forWho: String, val text: String, val created: Long, val answered: Boolean, val answeredOn: LocalDate?, val answerNote: String) +data class KindDay(val day: LocalDate, val idx: Int, val shift: Int, val status: Int) + +/** Everything the person writes. Stays on the phone. */ +class Store private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "mercies.db", null, 1) { + val rev = mutableIntStateOf(0) + private fun bump() { rev.intValue++ } + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE note(id INTEGER PRIMARY KEY AUTOINCREMENT, day TEXT, text TEXT, created INTEGER)") + db.execSQL("CREATE INDEX note_day ON note(day)") + db.execSQL("CREATE TABLE kind(day TEXT PRIMARY KEY, idx INTEGER, shift INTEGER, status INTEGER)") + db.execSQL("CREATE TABLE prayer(id INTEGER PRIMARY KEY AUTOINCREMENT, for_who TEXT, text TEXT, created INTEGER, answered INTEGER, answered_on TEXT, answer_note TEXT)") + } + override fun onUpgrade(db: SQLiteDatabase, o: Int, n: Int) {} + + // ---------------- jar ---------------- + fun addNote(text: String, day: LocalDate = LocalDate.now()) { + if (text.isBlank()) return + writableDatabase.insert("note", null, ContentValues().apply { + put("day", day.toString()); put("text", text.trim()); put("created", System.currentTimeMillis()) + }) + bump() + } + fun updateNote(id: Long, text: String) { + if (text.isBlank()) deleteNote(id) else { + writableDatabase.update("note", ContentValues().apply { put("text", text.trim()) }, "id=?", arrayOf(id.toString())); bump() + } + } + fun deleteNote(id: Long) { writableDatabase.delete("note", "id=?", arrayOf(id.toString())); bump() } + + private fun notes(where: String?, args: Array?, order: String = "created DESC", limit: String? = null): List = + readableDatabase.query("note", arrayOf("id", "day", "text", "created"), where, args, null, null, order, limit).use { c -> + buildList { while (c.moveToNext()) add(JarNote(c.getLong(0), LocalDate.parse(c.getString(1)), c.getString(2), c.getLong(3))) } + } + fun allNotes() = notes(null, null) + fun notesOn(day: LocalDate) = notes("day=?", arrayOf(day.toString()), "created ASC") + fun noteCount(): Int = readableDatabase.rawQuery("SELECT count(*) FROM note", null).use { if (it.moveToFirst()) it.getInt(0) else 0 } + fun daysWithNotes(): Int = readableDatabase.rawQuery("SELECT count(DISTINCT day) FROM note", null).use { if (it.moveToFirst()) it.getInt(0) else 0 } + /** a random note, avoiding today's when there are older ones */ + fun randomNote(exclude: Long = -1): JarNote? { + val today = LocalDate.now().toString() + return notes("day<>? AND id<>?", arrayOf(today, exclude.toString()), "RANDOM()", "1").firstOrNull() + ?: notes("id<>?", arrayOf(exclude.toString()), "RANDOM()", "1").firstOrNull() + } + + // ---------------- kindness ---------------- + fun kindToday(day: LocalDate = LocalDate.now()): KindDay { + readableDatabase.rawQuery("SELECT idx,shift,status FROM kind WHERE day=?", arrayOf(day.toString())).use { c -> + if (c.moveToFirst()) return KindDay(day, c.getInt(0), c.getInt(1), c.getInt(2)) + } + return KindDay(day, Kindness.indexFor(day, 0), 0, 0) + } + private fun saveKind(k: KindDay) { + writableDatabase.insertWithOnConflict("kind", null, ContentValues().apply { + put("day", k.day.toString()); put("idx", k.idx); put("shift", k.shift); put("status", k.status) + }, SQLiteDatabase.CONFLICT_REPLACE) + bump() + } + fun markKind(status: Int) { saveKind(kindToday().copy(status = status)) } + fun anotherKind() { + val k = kindToday(); val s = k.shift + 1 + saveKind(k.copy(shift = s, idx = Kindness.indexFor(k.day, s), status = 0)) + } + fun kindDoneCount(): Int = readableDatabase.rawQuery("SELECT count(*) FROM kind WHERE status=1", null).use { if (it.moveToFirst()) it.getInt(0) else 0 } + fun kindDone(): List = readableDatabase.rawQuery("SELECT day,idx,shift,status FROM kind WHERE status=1 ORDER BY day DESC", null).use { c -> + buildList { while (c.moveToNext()) add(KindDay(LocalDate.parse(c.getString(0)), c.getInt(1), c.getInt(2), c.getInt(3))) } + } + + // ---------------- prayers ---------------- + fun savePrayer(id: Long?, forWho: String, text: String) { + val v = ContentValues().apply { put("for_who", forWho.trim()); put("text", text.trim()) } + if (id == null) { + v.put("created", System.currentTimeMillis()); v.put("answered", 0); v.put("answer_note", "") + writableDatabase.insert("prayer", null, v) + } else writableDatabase.update("prayer", v, "id=?", arrayOf(id.toString())) + bump() + } + fun setAnswered(id: Long, answered: Boolean, note: String = "") { + writableDatabase.update("prayer", ContentValues().apply { + put("answered", if (answered) 1 else 0) + put("answered_on", if (answered) LocalDate.now().toString() else null) + put("answer_note", note.trim()) + }, "id=?", arrayOf(id.toString())) + bump() + } + fun deletePrayer(id: Long) { writableDatabase.delete("prayer", "id=?", arrayOf(id.toString())); bump() } + fun prayers(answered: Boolean): List = + readableDatabase.rawQuery("SELECT id,for_who,text,created,answered,answered_on,answer_note FROM prayer WHERE answered=? ORDER BY " + + (if (answered) "answered_on DESC, created DESC" else "created DESC"), arrayOf(if (answered) "1" else "0")).use { c -> + buildList { + while (c.moveToNext()) add(Prayer(c.getLong(0), c.getString(1) ?: "", c.getString(2) ?: "", c.getLong(3), c.getInt(4) == 1, + c.getString(5)?.let { LocalDate.parse(it) }, c.getString(6) ?: "")) + } + } + fun prayer(id: Long): Prayer? = (prayers(false) + prayers(true)).firstOrNull { it.id == id } + + /** Plain-text backup of everything. */ + fun exportText(): String = buildString { + appendLine("SIMPLE MERCIES · exported ${LocalDate.now()}") + appendLine() + appendLine("THE JAR") + for (n in allNotes().sortedBy { it.created }) appendLine("${n.day} ${n.text}") + appendLine() + appendLine("PRAYERS") + for (p in prayers(false)) appendLine("[ ] ${p.forWho.ifBlank { "Prayer" }}: ${p.text}") + for (p in prayers(true)) appendLine("[answered ${p.answeredOn}] ${p.forWho.ifBlank { "Prayer" }}: ${p.text}" + if (p.answerNote.isNotBlank()) " (${p.answerNote})" else "") + appendLine() + appendLine("KINDNESSES DONE") + for (k in kindDone()) appendLine("${k.day} ${Kindness.all[k.idx]}") + } + + companion object { + @Volatile private var inst: Store? = null + fun get(ctx: Context) = inst ?: synchronized(this) { inst ?: Store(ctx.applicationContext).also { inst = it } } + } +} diff --git a/app/src/main/java/space/bloggin/mercies/Tabs.kt b/app/src/main/java/space/bloggin/mercies/Tabs.kt new file mode 100644 index 0000000..3f06af3 --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Tabs.kt @@ -0,0 +1,297 @@ +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) { ShareKit.shareText(ctx, prayerMessage(p), "A prayer for you") } + 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() + ShareKit.shareText(ctx, prayerMessage(Prayer(0, who, text, 0, false, null, "")), "A prayer for you") + } + 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("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, and a place to write prayers and send them to the people you're praying for.\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.0", + color = l.ink, fontFamily = Fonts.body, fontSize = 18.sp, lineHeight = 26.sp, textAlign = TextAlign.Center) + } + } +} diff --git a/app/src/main/java/space/bloggin/mercies/Theme.kt b/app/src/main/java/space/bloggin/mercies/Theme.kt new file mode 100644 index 0000000..206824e --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Theme.kt @@ -0,0 +1,203 @@ +package space.bloggin.mercies + +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +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 +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +data class Look( + val name: String, + val image: Int, + val dark: Boolean, + val ink: Color, + val faint: Color, + val accent: Color, + val leaf: Color, + val panel: Color, +) + +object Looks { + val all = listOf( + Look("Linen", R.drawable.bg_linen, false, Color(0xFF3A2E26), Color(0xFF7A6A5C), Color(0xFFA0525A), Color(0xFF6E7F58), Color(0xF0F6F0E4)), + Look("Rose linen", R.drawable.bg_linen_rose, false, Color(0xFF3E2A2A), Color(0xFF7E6262), Color(0xFF9C4552), Color(0xFF6E7F58), Color(0xF0F8EDEA)), + Look("Sage linen", R.drawable.bg_linen_sage, false, Color(0xFF2E3428), Color(0xFF6A725E), Color(0xFF9A5A50), Color(0xFF5E7048), Color(0xF0EFF2E8)), + Look("Dusk", R.drawable.bg_dusk, true, Color(0xFFE8DED2), Color(0xFFA89A8C), Color(0xFFD8949A), Color(0xFFA2B48A), Color(0xF0252226)), + ) +} + +object Fonts { + val fell = FontFamily(Font(R.font.imfellenglish_regular), Font(R.font.imfellenglish_italic, style = FontStyle.Italic)) + val fellSc = FontFamily(Font(R.font.imfellenglishsc_regular)) + val body = FontFamily( + Font(R.font.ebgaramond_regular), Font(R.font.ebgaramond_italic, style = FontStyle.Italic), Font(R.font.ebgaramond_bold, weight = FontWeight.Bold) + ) + val ui = FontFamily.SansSerif +} + +class Prefs private constructor(ctx: Context) { + private val p = ctx.getSharedPreferences("mercies", Context.MODE_PRIVATE) + private val _look = mutableIntStateOf(p.getInt("look", 0)) + private val _night = mutableStateOf(p.getBoolean("night", false)) + private val _size = mutableFloatStateOf(p.getFloat("size", 20f)) + private val _eve = mutableStateOf(p.getBoolean("eve", false)) + private val _eveH = mutableIntStateOf(p.getInt("eveH", 20)) + private val _eveM = mutableIntStateOf(p.getInt("eveM", 30)) + private val _morn = mutableStateOf(p.getBoolean("morn", false)) + private val _mornH = mutableIntStateOf(p.getInt("mornH", 7)) + private val _mornM = mutableIntStateOf(p.getInt("mornM", 45)) + private val _shake = mutableStateOf(p.getBoolean("shake", true)) + + var look: Int get() = _look.intValue; set(v) { _look.intValue = v; p.edit().putInt("look", v).apply() } + var night: Boolean get() = _night.value; set(v) { _night.value = v; p.edit().putBoolean("night", v).apply() } + var size: Float get() = _size.floatValue; set(v) { _size.floatValue = v; p.edit().putFloat("size", v).apply() } + var eve: Boolean get() = _eve.value; set(v) { _eve.value = v; p.edit().putBoolean("eve", v).apply() } + var eveH: Int get() = _eveH.intValue; set(v) { _eveH.intValue = v; p.edit().putInt("eveH", v).apply() } + var eveM: Int get() = _eveM.intValue; set(v) { _eveM.intValue = v; p.edit().putInt("eveM", v).apply() } + var morn: Boolean get() = _morn.value; set(v) { _morn.value = v; p.edit().putBoolean("morn", v).apply() } + var mornH: Int get() = _mornH.intValue; set(v) { _mornH.intValue = v; p.edit().putInt("mornH", v).apply() } + var mornM: Int get() = _mornM.intValue; set(v) { _mornM.intValue = v; p.edit().putInt("mornM", v).apply() } + var shake: Boolean get() = _shake.value; set(v) { _shake.value = v; p.edit().putBoolean("shake", v).apply() } + + val active: Look get() = Looks.all[if (night) 3 else look.coerceIn(0, 2)] + + companion object { + @Volatile private var inst: Prefs? = null + fun get(ctx: Context) = inst ?: synchronized(this) { inst ?: Prefs(ctx.applicationContext).also { inst = it } } + } +} + +val LocalLook = compositionLocalOf { Looks.all[0] } + +fun onInk(l: Look) = if (l.dark) Color(0xFF1C1A1C) else Color(0xFFFAF5EC) + +@Composable +fun LookFill(l: Look, modifier: Modifier = Modifier) { + Image(painterResource(l.image), null, modifier.fillMaxSize(), contentScale = ContentScale.Crop) +} + +@Composable +fun Panel(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + val l = LocalLook.current + Column(modifier.background(l.panel).border(1.dp, l.faint.copy(alpha = 0.4f)), content = content) +} + +@Composable +fun SqButton(text: String, modifier: Modifier = Modifier, icon: ImageVector? = null, filled: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) { + val l = LocalLook.current + val fg = if (filled) onInk(l) else l.ink + Row( + modifier.background(if (filled) l.ink else Color.Transparent).border(1.dp, l.ink.copy(alpha = if (enabled) 0.8f else 0.3f)) + .clickable(enabled = enabled, onClick = onClick).padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center + ) { + if (icon != null) { Icon(icon, null, tint = fg, modifier = Modifier.size(18.dp)); if (text.isNotEmpty()) Spacer(Modifier.width(7.dp)) } + if (text.isNotEmpty()) Text(text, color = fg.copy(alpha = if (enabled) 1f else 0.4f), fontFamily = Fonts.ui, fontSize = 15.sp, maxLines = 1) + } +} + +@Composable +fun IconBtn(icon: ImageVector, desc: String, tint: Color? = null, size: Int = 24, onClick: () -> Unit) { + val l = LocalLook.current + Box(Modifier.size(44.dp).clickable(onClick = onClick), contentAlignment = Alignment.Center) { + Icon(icon, desc, tint = tint ?: l.ink, modifier = Modifier.size(size.dp)) + } +} + +@Composable +fun Heading(text: String, modifier: Modifier = Modifier, size: Int = 30) { + val l = LocalLook.current + Text(text, modifier, color = l.ink, fontFamily = Fonts.fellSc, fontSize = size.sp, lineHeight = (size * 1.1).sp) +} + +@Composable +fun Label(text: String, modifier: Modifier = Modifier) { + val l = LocalLook.current + Text(text.uppercase(), modifier.padding(top = 18.dp, bottom = 8.dp), color = l.accent, fontFamily = Fonts.fellSc, fontSize = 15.sp, letterSpacing = 1.5.sp) +} + +@Composable +fun Sprig(modifier: Modifier = Modifier) { + val l = LocalLook.current + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Box(Modifier.width(60.dp).height(1.dp).background(l.leaf.copy(alpha = 0.6f))) + Text(" ❀ ", color = l.accent, fontSize = 16.sp) + Box(Modifier.width(60.dp).height(1.dp).background(l.leaf.copy(alpha = 0.6f))) + } +} + +@Composable +fun Field(value: String, onChange: (String) -> Unit, hint: String, modifier: Modifier = Modifier, minHeight: Dp = 0.dp, single: Boolean = false, serif: Boolean = true) { + val l = LocalLook.current + BasicTextField( + value = value, onValueChange = onChange, singleLine = single, + modifier = modifier.background(l.panel).border(1.dp, l.ink.copy(alpha = 0.55f)).heightIn(min = minHeight).padding(12.dp), + textStyle = TextStyle(color = l.ink, fontFamily = if (serif) Fonts.body else Fonts.ui, fontSize = 19.sp, lineHeight = 26.sp), + cursorBrush = SolidColor(l.ink), + decorationBox = { inner -> + if (value.isEmpty()) Text(hint, color = l.faint, fontFamily = if (serif) Fonts.fell else Fonts.ui, fontStyle = if (serif) FontStyle.Italic else FontStyle.Normal, fontSize = 18.sp) + inner() + } + ) +} + +@Composable +fun ToggleRow(label: String, checked: Boolean, sub: String? = null, onChange: (Boolean) -> Unit) { + val l = LocalLook.current + Row(Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(label, color = l.ink, fontFamily = Fonts.ui, fontSize = 16.sp) + if (sub != null) Text(sub, color = l.faint, fontFamily = Fonts.ui, fontSize = 13.sp) + } + Switch(checked, onChange, colors = SwitchDefaults.colors( + checkedThumbColor = onInk(l), checkedTrackColor = l.ink, uncheckedThumbColor = l.ink, + uncheckedTrackColor = Color.Transparent, uncheckedBorderColor = l.ink, checkedBorderColor = l.ink)) + } +} + +@Composable +fun SliderRow(label: String, value: Float, range: ClosedFloatingPointRange, display: String, onChange: (Float) -> Unit) { + val l = LocalLook.current + Column(Modifier.fillMaxWidth()) { + Row { Text(label, Modifier.weight(1f), color = l.ink, fontFamily = Fonts.ui, fontSize = 16.sp); Text(display, color = l.faint, fontFamily = Fonts.ui, fontSize = 14.sp) } + Slider(value, onChange, valueRange = range, colors = SliderDefaults.colors(thumbColor = l.ink, activeTrackColor = l.ink, inactiveTrackColor = l.ink.copy(alpha = 0.25f))) + } +} + +@Composable +fun Rule(modifier: Modifier = Modifier, alpha: Float = 0.3f) { + val l = LocalLook.current + Box(modifier.fillMaxWidth().height(1.dp).background(l.faint.copy(alpha = alpha))) +} + +@Composable +fun Small(text: String, modifier: Modifier = Modifier, center: Boolean = false) { + val l = LocalLook.current + Text(text, modifier, color = l.faint, fontFamily = Fonts.ui, fontSize = 13.sp, textAlign = if (center) TextAlign.Center else null) +}