Simple Mercies 1.2: X length warning and Daily Notepad

This commit is contained in:
2026-10-19 15:54:10 +00:00
parent 03fb7b2a25
commit 064c0a21a1
5 changed files with 464 additions and 8 deletions
@@ -34,13 +34,24 @@ sealed class Over {
data class EditPrayer(val id: Long?) : Over()
data class Answer(val id: Long) : Over()
data class EditNote(val note: JarNote) : Over()
data class EditPad(val id: Long) : Over()
data object Settings : Over()
data object About : Over()
}
class AppState(val ctx: Context, val store: Store, val prefs: Prefs) {
/** 0 jar, 1 kindness, 2 prayers */
/** 0 jar, 1 kindness, 2 prayers, 3 notepad */
var tab by mutableIntStateOf(0)
/** a long message waiting on the "X has a 280 character limit" question */
var xWarn by mutableStateOf<XWarn?>(null)
/** a heart flying out of a ticked to-do; changes every tick so a new one starts */
var heart by mutableStateOf<HeartBurst?>(null)
/** Send text through the share sheet, warning first if it is too long for X without a blue checkmark. */
fun sendText(text: String, subject: String, asPicture: Card? = null) {
if (!prefs.xBlue && XLimit.weighted(text) > XLimit.MAX) xWarn = XWarn(text, subject, asPicture)
else ShareKit.shareText(ctx, text, subject)
}
val overs: SnapshotStateList<Over> = mutableStateListOf()
var browsing by mutableStateOf(false)
fun open(o: Over) { overs.add(o) }
@@ -86,6 +97,7 @@ class MainActivity : ComponentActivity() {
"jar" -> { state.overs.clear(); state.tab = 0 }
"kind" -> { state.overs.clear(); state.tab = 1 }
"prayers" -> { state.overs.clear(); state.tab = 2 }
"pad" -> { state.overs.clear(); state.tab = 3 }
}
}
override fun onPause() { super.onPause(); MercyWidget.refresh(this) }
@@ -101,7 +113,8 @@ fun App(st: AppState) {
when (st.tab) {
0 -> if (st.browsing) BrowseJar(st) else JarTab(st)
1 -> KindTab(st)
else -> PrayersTab(st)
2 -> PrayersTab(st)
else -> NotepadTab(st)
}
}
TabBar(st)
@@ -113,9 +126,12 @@ fun App(st: AppState) {
is Over.Answer -> AnswerSheet(st, o.id)
is Over.EditNote -> NoteEditor(st, o.note)
Over.Settings -> SettingsScreen(st)
is Over.EditPad -> PadEditor(st, o.id)
Over.About -> AboutScreen(st)
}
}
st.xWarn?.let { XWarnDialog(st, it) }
st.heart?.let { HeartOverlay(it) { if (st.heart === it) st.heart = null } }
}
}
@@ -128,6 +144,7 @@ fun TabBar(st: AppState) {
TabItem("The Jar", Icons.Filled.Inventory2, st.tab == 0, Modifier.weight(1f)) { st.tab = 0; st.browsing = false; st.overs.clear() }
TabItem("Kindness", Icons.Filled.VolunteerActivism, st.tab == 1, Modifier.weight(1f)) { st.tab = 1; st.overs.clear() }
TabItem("Prayers", Icons.Filled.EditNote, st.tab == 2, Modifier.weight(1f)) { st.tab = 2; st.overs.clear() }
TabItem("Notepad", Icons.Filled.StickyNote2, st.tab == 3, Modifier.weight(1f)) { st.tab = 3; st.overs.clear() }
}
}
}
@@ -0,0 +1,379 @@
package space.bloggin.mercies
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.max
import kotlin.math.sin
// ------------------------------------------------------------------ the X (Twitter) length warning
/** X counts most letters as 1 and emoji / Chinese / Japanese / Korean characters as 2. Without a blue checkmark a post stops at 280. */
object XLimit {
const val MAX = 280
fun weighted(text: String): Int {
var n = 0
var i = 0
while (i < text.length) {
val cp = text.codePointAt(i)
n += if (cp in 0..4351 || cp in 8192..8205 || cp in 8208..8223 || cp in 8242..8247) 1 else 2
i += Character.charCount(cp)
}
return n
}
}
data class XWarn(val text: String, val subject: String, val picture: Card?)
@Composable
fun XWarnDialog(st: AppState, w: XWarn) {
val l = LocalLook.current
val count = remember(w) { XLimit.weighted(w.text) }
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { st.xWarn = null },
contentAlignment = Alignment.Center
) {
Panel(
Modifier.padding(22.dp).widthIn(max = 520.dp).fillMaxWidth()
.background(l.panel.copy(alpha = 1f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
) {
Column(Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Warning, null, tint = l.accent, modifier = Modifier.size(26.dp))
Spacer(Modifier.width(10.dp))
Heading("Too long for X?", size = 24)
}
Spacer(Modifier.height(10.dp))
Text(
"This is $count characters. If you're posting it to X (Twitter) and you don't have a blue checkmark, " +
"X stops at ${XLimit.MAX} characters, so it will not post.",
color = l.ink, fontFamily = Fonts.body, fontSize = 19.sp, lineHeight = 26.sp
)
Spacer(Modifier.height(8.dp))
Text(
"Texts, email, Facebook and the rest have no such limit. Sending it as a picture works on X too.",
color = l.faint, fontFamily = Fonts.body, fontStyle = FontStyle.Italic, fontSize = 17.sp, lineHeight = 23.sp
)
Spacer(Modifier.height(16.dp))
if (w.picture != null) {
SqButton("Send as a picture instead", icon = Icons.Filled.Image, filled = true, modifier = Modifier.fillMaxWidth()) {
st.xWarn = null; st.open(Over.ShareCard(w.picture, ""))
}
Spacer(Modifier.height(8.dp))
}
SqButton("Send the text anyway", icon = Icons.Filled.Send, filled = w.picture == null, modifier = Modifier.fillMaxWidth()) {
st.xWarn = null; ShareKit.shareText(st.ctx, w.text, w.subject)
}
Spacer(Modifier.height(8.dp))
SqButton("I have a blue checkmark, send it", icon = Icons.Filled.Verified, modifier = Modifier.fillMaxWidth()) {
st.prefs.xBlue = true; st.xWarn = null; ShareKit.shareText(st.ctx, w.text, w.subject)
}
Spacer(Modifier.height(8.dp))
SqButton("Cancel", modifier = Modifier.fillMaxWidth()) { st.xWarn = null }
Small("You can change the checkmark setting any time in Settings.", Modifier.padding(top = 10.dp))
}
}
}
}
// ------------------------------------------------------------------ the heart
/** origin is where the ticked box sits on screen; key makes every tick a fresh heart. */
data class HeartBurst(val origin: Offset, val color: Color, val key: Long)
fun heartPath(w: Float, h: Float): Path = Path().apply {
// a plump hand-drawn style heart in a w x h box
moveTo(w * 0.5f, h * 0.95f)
cubicTo(w * 0.18f, h * 0.72f, w * -0.02f, h * 0.5f, w * 0.02f, h * 0.28f)
cubicTo(w * 0.06f, h * 0.06f, w * 0.34f, h * -0.04f, w * 0.5f, h * 0.2f)
cubicTo(w * 0.66f, h * -0.04f, w * 0.94f, h * 0.06f, w * 0.98f, h * 0.28f)
cubicTo(w * 1.02f, h * 0.5f, w * 0.82f, h * 0.72f, w * 0.5f, h * 0.95f)
close()
}
/**
* The heart pops out of the note, swoops up into the middle of the screen,
* swells until it fills the whole screen, then fades away.
*/
@Composable
fun HeartOverlay(b: HeartBurst, onDone: () -> Unit) {
val t = remember(b.key) { Animatable(0f) }
val done by rememberUpdatedState(onDone)
LaunchedEffect(b.key) {
t.snapTo(0f)
t.animateTo(1f, tween(2100, easing = LinearEasing))
done()
}
val p = t.value
Canvas(Modifier.fillMaxSize()) {
val W = size.width; val H = size.height
val base = 30.dp.toPx()
fun ease(x: Float) = 1f - (1f - x) * (1f - x) * (1f - x) // ease out
fun easeIn(x: Float) = x * x * x
val fly = (p / 0.36f).coerceIn(0f, 1f) // 0.00 - 0.36 flies out
val grow = ((p - 0.36f) / 0.32f).coerceIn(0f, 1f) // 0.36 - 0.68 fills the screen
val fade = ((p - 0.66f) / 0.34f).coerceIn(0f, 1f) // 0.66 - 1.00 fades
val center = Offset(W / 2f, H * 0.46f)
// fly along a curve that lifts up and out of the note first
val f = ease(fly)
val ctrl = Offset(b.origin.x + (center.x - b.origin.x) * 0.15f, b.origin.y - H * 0.28f)
val pos = Offset(
(1 - f) * (1 - f) * b.origin.x + 2 * (1 - f) * f * ctrl.x + f * f * center.x,
(1 - f) * (1 - f) * b.origin.y + 2 * (1 - f) * f * ctrl.y + f * f * center.y
)
val pop = if (fly < 0.25f) 0.4f + 2.6f * (fly / 0.25f) else 1f + 0.08f * sin(fly * 18f) * (1f - fly) // pop out with a little wobble
val flyScale = pop * (1f + 2.2f * f)
// big enough that the heart's narrow tip still covers every corner
val cover = max(W, H) * 3.4f / base
val s = flyScale + (cover - flyScale) * easeIn(grow)
val alpha = (1f - fade).coerceIn(0f, 1f)
val tilt = (1f - f) * -14f + 6f * sin(fly * 7f) * (1f - grow)
val size1 = base * s
translate(pos.x - size1 / 2f, pos.y - size1 * 0.52f) {
rotate(tilt, pivot = Offset(size1 / 2f, size1 / 2f)) {
val path = heartPath(size1, size1)
drawPath(path, b.color.copy(alpha = alpha))
// soft highlight on the left lobe
scale(0.36f, pivot = Offset(size1 * 0.28f, size1 * 0.3f)) {
drawPath(heartPath(size1, size1), Color.White.copy(alpha = 0.22f * alpha))
}
drawPath(path, Color.Black.copy(alpha = 0.18f * alpha * (1f - grow)), style = Stroke(width = 2.dp.toPx()))
}
}
}
}
// ------------------------------------------------------------------ the notepad
private val kinds = listOf("Thoughts", "To-do list", "Dream diary")
private val kindIcons = listOf(Icons.Filled.Lightbulb, Icons.Filled.Checklist, Icons.Filled.Bedtime)
private val dayHead = DateTimeFormatter.ofPattern("EEEE, MMMM d")
private val dayHeadYear = DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy")
private val longDay = DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy")
private fun dayTitle(d: LocalDate): String {
val today = LocalDate.now()
return when (d) {
today -> "Today"
today.minusDays(1) -> "Yesterday"
else -> d.format(if (d.year == today.year) dayHead else dayHeadYear)
}
}
@Composable
fun KindPicker(st: AppState) {
val l = LocalLook.current
var open by remember { mutableStateOf(false) }
val k = st.prefs.padKind.coerceIn(0, 2)
Box {
Row(
Modifier.fillMaxWidth().background(l.panel).border(1.dp, l.ink.copy(alpha = 0.8f)).clickable { open = true }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(kindIcons[k], null, tint = l.accent, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(10.dp))
Text(kinds[k], Modifier.weight(1f), color = l.ink, fontFamily = Fonts.fellSc, fontSize = 22.sp)
Icon(if (open) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown, "Choose", tint = l.ink, modifier = Modifier.size(28.dp))
}
DropdownMenu(open, { open = false }, shape = RectangleShape, containerColor = l.panel.copy(alpha = 1f)) {
kinds.forEachIndexed { i, name ->
DropdownMenuItem(
text = { Text(name, color = if (i == k) l.accent else l.ink, fontFamily = Fonts.fellSc, fontSize = 20.sp) },
leadingIcon = { Icon(kindIcons[i], null, tint = if (i == k) l.accent else l.ink) },
onClick = { st.prefs.padKind = i; open = false }
)
}
}
}
}
@Composable
fun NotepadTab(st: AppState) {
val l = LocalLook.current
val rev = st.store.rev.intValue
val k = st.prefs.padKind.coerceIn(0, 2)
val items = remember(rev, k) { st.store.pads(k) }
var text by remember(k) { mutableStateOf("") }
var showDone by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
LazyColumn(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
item { Header(st, "Daily Notepad", LocalDate.now().format(longDay)) }
item {
Column(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp, vertical = 10.dp)) {
KindPicker(st)
Spacer(Modifier.height(12.dp))
Panel(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
when (k) {
0 -> {
Text("What's on your mind?", color = l.ink, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 22.sp)
Spacer(Modifier.height(10.dp))
Field(text, { text = it }, "A thought, a line, a little something…", Modifier.fillMaxWidth(), minHeight = 110.dp)
Spacer(Modifier.height(10.dp))
SqButton("Write it down", icon = Icons.Filled.Add, filled = true, enabled = text.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
st.store.addPad(0, text); text = ""
}
}
1 -> {
Text("What needs doing?", color = l.ink, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 22.sp)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Field(text, { text = it }, "Add a to-do…", Modifier.weight(1f), single = true)
Spacer(Modifier.width(8.dp))
SqButton("Add", icon = Icons.Filled.Add, filled = true, enabled = text.isNotBlank()) {
st.store.addPad(1, text); text = ""
}
}
Small("Tick one off and see what happens.", Modifier.padding(top = 8.dp))
}
else -> {
Text("Did you dream last night?", color = l.ink, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 22.sp)
Small("Write it down first thing, before it fades.", Modifier.padding(top = 2.dp))
Spacer(Modifier.height(10.dp))
Field(text, { text = it }, "I was standing in…", Modifier.fillMaxWidth(), minHeight = 150.dp)
Spacer(Modifier.height(10.dp))
SqButton("Keep this dream", icon = Icons.Filled.Bedtime, filled = true, enabled = text.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
st.store.addPad(2, text); text = ""
}
}
}
}
}
}
}
if (k == 1) {
val open = items.filter { !it.done }
val done = items.filter { it.done }.sortedByDescending { it.doneAt }
item {
Label(if (open.isEmpty()) "Nothing left to do" else "To do · ${open.size}", Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp))
if (open.isEmpty() && done.isEmpty()) Small("Your list is empty. Add something above.", Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp))
}
items(open, key = { it.id }) { t ->
TodoRow(st, t) { origin ->
st.store.setPadDone(t.id, true)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
st.heart = HeartBurst(origin, l.accent, System.nanoTime())
}
}
if (done.isNotEmpty()) {
item {
Row(Modifier.widthIn(max = 640.dp).fillMaxWidth().clickable { showDone = !showDone }.padding(horizontal = 18.dp), verticalAlignment = Alignment.CenterVertically) {
Label("Done · ${done.size}", Modifier.weight(1f))
Icon(if (showDone) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null, tint = l.ink)
}
}
if (showDone) items(done, key = { it.id }) { t -> TodoRow(st, t) { st.store.setPadDone(t.id, false) } }
}
} else {
val byDay = items.groupBy { it.day }
if (items.isEmpty()) item {
Small(if (k == 0) "Your thoughts will gather here, day by day." else "Your dreams will gather here, night by night.",
Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp, vertical = 8.dp), center = true)
}
byDay.forEach { (day, list) ->
item(key = "d$k$day") { Label(dayTitle(day), Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp)) }
items(list, key = { it.id }) { n ->
Panel(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp, vertical = 4.dp).clickable { st.open(Over.EditPad(n.id)) }) {
Row(Modifier.padding(14.dp)) {
Text(if (k == 2) "☾ " else "❀ ", color = l.accent, fontSize = 17.sp)
Text(n.text, color = l.ink, fontFamily = Fonts.body, fontSize = st.prefs.size.sp, lineHeight = (st.prefs.size * 1.35f).sp,
fontStyle = if (k == 2) FontStyle.Italic else FontStyle.Normal)
}
}
}
}
}
item { Spacer(Modifier.height(30.dp)) }
}
}
@Composable
fun TodoRow(st: AppState, t: PadItem, onTick: (Offset) -> Unit) {
val l = LocalLook.current
var center by remember { mutableStateOf(Offset.Zero) }
Panel(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp, vertical = 4.dp)) {
Row(Modifier.fillMaxWidth().padding(start = 6.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
// square tick box
Box(
Modifier.size(48.dp).onGloballyPositioned { center = it.boundsInRoot().center }.clickable { onTick(center) },
contentAlignment = Alignment.Center
) {
Box(Modifier.size(26.dp).border(2.dp, l.ink).background(if (t.done) l.ink else Color.Transparent), contentAlignment = Alignment.Center) {
if (t.done) Icon(Icons.Filled.Check, "Done", tint = onInk(l), modifier = Modifier.size(20.dp))
}
}
Text(
t.text, Modifier.weight(1f).clickable { st.open(Over.EditPad(t.id)) }.padding(vertical = 12.dp),
color = if (t.done) l.faint else l.ink, fontFamily = Fonts.body, fontSize = st.prefs.size.sp, lineHeight = (st.prefs.size * 1.3f).sp,
textDecoration = if (t.done) TextDecoration.LineThrough else null
)
if (t.done) Text("♥", Modifier.padding(horizontal = 10.dp), color = l.accent, fontSize = 18.sp)
}
}
}
@Composable
fun PadEditor(st: AppState, id: Long) {
val item = remember(id) { st.store.pad(id) }
if (item == null) { LaunchedEffect(id) { st.back() }; return }
var text by remember(id) { mutableStateOf(item.text) }
val title = when (item.kind) { 0 -> "A thought"; 1 -> "A to-do"; else -> "A dream" }
Overlay(st, title) {
Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp)) {
Small(item.day.format(longDay), Modifier.padding(bottom = 8.dp))
Field(text, { text = it }, "…", Modifier.fillMaxWidth(), minHeight = if (item.kind == 1) 70.dp else 180.dp)
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Delete", icon = Icons.Filled.Delete) { st.store.deletePad(id); st.back() }
if (item.kind != 1) SqButton("Send", icon = Icons.Filled.Share) {
st.store.updatePad(id, text); st.back()
st.sendText(text.trim(), title, Card(if (item.kind == 2) "A dream" else "A thought", text.trim(), "Simple Mercies"))
}
Spacer(Modifier.weight(1f))
SqButton("Save", icon = Icons.Filled.Check, filled = true) { st.store.updatePad(id, text); st.back() }
}
}
}
}
@@ -10,9 +10,11 @@ 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)
/** A notepad entry. kind 0 = a thought, 1 = a to-do, 2 = a dream. */
data class PadItem(val id: Long, val kind: Int, val day: LocalDate, val text: String, val done: Boolean, val created: Long, val doneAt: Long)
/** Everything the person writes. Stays on the phone. */
class Store private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "mercies.db", null, 1) {
class Store private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "mercies.db", null, 2) {
val rev = mutableIntStateOf(0)
private fun bump() { rev.intValue++ }
@@ -21,8 +23,14 @@ class Store private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "mercies.d
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)")
createPad(db)
}
override fun onUpgrade(db: SQLiteDatabase, o: Int, n: Int) {}
private fun createPad(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS pad(id INTEGER PRIMARY KEY AUTOINCREMENT, kind INTEGER, day TEXT, text TEXT, done INTEGER, created INTEGER, done_at INTEGER)")
db.execSQL("CREATE INDEX IF NOT EXISTS pad_kind ON pad(kind, created)")
}
// version 1 (Simple Mercies 1.0 and 1.1) had no notepad; everything already written is kept
override fun onUpgrade(db: SQLiteDatabase, o: Int, n: Int) { if (o < 2) createPad(db) }
// ---------------- jar ----------------
fun addNote(text: String, day: LocalDate = LocalDate.now()) {
@@ -105,6 +113,36 @@ class Store private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "mercies.d
}
fun prayer(id: Long): Prayer? = (prayers(false) + prayers(true)).firstOrNull { it.id == id }
// ---------------- notepad ----------------
fun addPad(kind: Int, text: String, day: LocalDate = LocalDate.now()) {
if (text.isBlank()) return
writableDatabase.insert("pad", null, ContentValues().apply {
put("kind", kind); put("day", day.toString()); put("text", text.trim()); put("done", 0)
put("created", System.currentTimeMillis()); put("done_at", 0L)
})
bump()
}
fun updatePad(id: Long, text: String) {
if (text.isBlank()) deletePad(id) else {
writableDatabase.update("pad", ContentValues().apply { put("text", text.trim()) }, "id=?", arrayOf(id.toString())); bump()
}
}
fun setPadDone(id: Long, done: Boolean) {
writableDatabase.update("pad", ContentValues().apply {
put("done", if (done) 1 else 0); put("done_at", if (done) System.currentTimeMillis() else 0L)
}, "id=?", arrayOf(id.toString()))
bump()
}
fun deletePad(id: Long) { writableDatabase.delete("pad", "id=?", arrayOf(id.toString())); bump() }
fun pads(kind: Int): List<PadItem> =
readableDatabase.rawQuery("SELECT id,kind,day,text,done,created,done_at FROM pad WHERE kind=? ORDER BY created DESC", arrayOf(kind.toString())).use { c ->
buildList {
while (c.moveToNext()) add(PadItem(c.getLong(0), c.getInt(1), LocalDate.parse(c.getString(2)), c.getString(3) ?: "",
c.getInt(4) == 1, c.getLong(5), c.getLong(6)))
}
}
fun pad(id: Long): PadItem? = (0..2).flatMap { pads(it) }.firstOrNull { it.id == id }
/** Plain-text backup of everything. */
fun exportText(): String = buildString {
appendLine("SIMPLE MERCIES · exported ${LocalDate.now()}")
@@ -118,6 +156,15 @@ class Store private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "mercies.d
appendLine()
appendLine("KINDNESSES DONE")
for (k in kindDone()) appendLine("${k.day} ${Kindness.all[k.idx]}")
appendLine()
appendLine("NOTEPAD: THOUGHTS")
for (t in pads(0).sortedBy { it.created }) appendLine("${t.day} ${t.text}")
appendLine()
appendLine("NOTEPAD: TO-DO")
for (t in pads(1).sortedBy { it.created }) appendLine((if (t.done) "[x] " else "[ ] ") + "${t.day} ${t.text}")
appendLine()
appendLine("NOTEPAD: DREAM DIARY")
for (t in pads(2).sortedBy { it.created }) appendLine("${t.day} ${t.text}")
}
companion object {
@@ -157,7 +157,9 @@ fun PrayerCard(st: AppState, p: Prayer) {
}
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("Send", icon = Icons.Filled.Send, filled = true) {
st.sendText(prayerMessage(p), "A prayer for you", Card(if (p.forWho.isBlank()) "A prayer for you" else "A prayer for ${p.forWho}", p.text, "Simple Mercies"))
}
SqButton("Picture", icon = Icons.Filled.Image) {
st.open(Over.ShareCard(Card(if (p.forWho.isBlank()) "A prayer for you" else "A prayer for ${p.forWho}", p.text, "Simple Mercies"), ""))
}
@@ -188,7 +190,8 @@ fun PrayerEditor(st: AppState, id: Long?) {
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")
st.sendText(prayerMessage(Prayer(0, who, text, 0, false, null, "")), "A prayer for you",
Card(if (who.isBlank()) "A prayer for you" else "A prayer for ${who.trim()}", text.trim(), "Simple Mercies"))
}
SqButton("Save", icon = Icons.Filled.Check, filled = true, enabled = text.isNotBlank()) { st.store.savePrayer(id, who, text); st.back() }
}
@@ -255,6 +258,10 @@ fun SettingsScreen(st: AppState) {
Label("The jar")
ToggleRow("Shake to pull a mercy", p.shake, "Turn off if it goes off in your pocket") { p.shake = it }
Label("Sending to X (Twitter)")
ToggleRow("I have a blue checkmark on X", p.xBlue,
"Without one, X stops a post at ${XLimit.MAX} characters. Leave this off and Simple Mercies will warn you before sending something longer.") { p.xBlue = it }
Label("Your words")
Small("Everything you write stays on this phone. Make a copy now and then so it's never lost.")
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -288,9 +295,9 @@ fun AboutScreen(st: AppState) {
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" +
Text("A gratitude jar, a daily kindness, a place to write prayers and send them to the people you're praying for, and a daily notepad for thoughts, to-dos and dreams.\n\n" +
"No account. No ads. No tracking. No internet permission. Everything stays on your phone.\n\n" +
"Fonts: IM Fell English and EB Garamond (SIL Open Font License). The linen and pressed-flower artwork was made for this app.\n\nVersion 1.1",
"Fonts: IM Fell English and EB Garamond (SIL Open Font License). The linen and pressed-flower artwork was made for this app.\n\nVersion 1.2",
color = l.ink, fontFamily = Fonts.body, fontSize = 18.sp, lineHeight = 26.sp, textAlign = TextAlign.Center)
}
}
@@ -73,6 +73,8 @@ class Prefs private constructor(ctx: Context) {
private val _mornH = mutableIntStateOf(p.getInt("mornH", 7))
private val _mornM = mutableIntStateOf(p.getInt("mornM", 45))
private val _shake = mutableStateOf(p.getBoolean("shake", true))
private val _xBlue = mutableStateOf(p.getBoolean("xBlue", false))
private val _padKind = mutableIntStateOf(p.getInt("padKind", 0))
private val _promptDay = mutableStateOf(p.getString("promptDay", "") ?: "")
private val _promptShift = mutableIntStateOf(p.getInt("promptShift", 0))
@@ -86,6 +88,10 @@ class Prefs private constructor(ctx: Context) {
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() }
/** the person has a blue checkmark on X, so the 280 character limit doesn't apply to them */
var xBlue: Boolean get() = _xBlue.value; set(v) { _xBlue.value = v; p.edit().putBoolean("xBlue", v).apply() }
/** which page of the notepad was open last: 0 thoughts, 1 to-do, 2 dreams */
var padKind: Int get() = _padKind.intValue; set(v) { _padKind.intValue = v; p.edit().putInt("padKind", v).apply() }
/** how many times today's question has been swapped; 0 on a new day */
val promptShift: Int get() = if (_promptDay.value == java.time.LocalDate.now().toString()) _promptShift.intValue else 0