Add app/src/main/java/space/bloggin/mercies

This commit is contained in:
2026-10-19 02:23:06 +00:00
parent 51aceb9f5a
commit 86c37a8722
3 changed files with 627 additions and 0 deletions
@@ -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<String>?, order: String = "created DESC", limit: String? = null): List<JarNote> =
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<KindDay> = 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<Prayer> =
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 } }
}
}
@@ -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)
}
}
}
@@ -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<Float>, 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)
}