Add app/src/main/java/space/bloggin/mercies
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user