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 EditPrayer(val id: Long?) : Over()
data class Answer(val id: Long) : Over() data class Answer(val id: Long) : Over()
data class EditNote(val note: JarNote) : Over() data class EditNote(val note: JarNote) : Over()
data class EditPad(val id: Long) : Over()
data object Settings : Over() data object Settings : Over()
data object About : Over() data object About : Over()
} }
class AppState(val ctx: Context, val store: Store, val prefs: Prefs) { 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) 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() val overs: SnapshotStateList<Over> = mutableStateListOf()
var browsing by mutableStateOf(false) var browsing by mutableStateOf(false)
fun open(o: Over) { overs.add(o) } fun open(o: Over) { overs.add(o) }
@@ -86,6 +97,7 @@ class MainActivity : ComponentActivity() {
"jar" -> { state.overs.clear(); state.tab = 0 } "jar" -> { state.overs.clear(); state.tab = 0 }
"kind" -> { state.overs.clear(); state.tab = 1 } "kind" -> { state.overs.clear(); state.tab = 1 }
"prayers" -> { state.overs.clear(); state.tab = 2 } "prayers" -> { state.overs.clear(); state.tab = 2 }
"pad" -> { state.overs.clear(); state.tab = 3 }
} }
} }
override fun onPause() { super.onPause(); MercyWidget.refresh(this) } override fun onPause() { super.onPause(); MercyWidget.refresh(this) }
@@ -101,7 +113,8 @@ fun App(st: AppState) {
when (st.tab) { when (st.tab) {
0 -> if (st.browsing) BrowseJar(st) else JarTab(st) 0 -> if (st.browsing) BrowseJar(st) else JarTab(st)
1 -> KindTab(st) 1 -> KindTab(st)
else -> PrayersTab(st) 2 -> PrayersTab(st)
else -> NotepadTab(st)
} }
} }
TabBar(st) TabBar(st)
@@ -113,9 +126,12 @@ fun App(st: AppState) {
is Over.Answer -> AnswerSheet(st, o.id) is Over.Answer -> AnswerSheet(st, o.id)
is Over.EditNote -> NoteEditor(st, o.note) is Over.EditNote -> NoteEditor(st, o.note)
Over.Settings -> SettingsScreen(st) Over.Settings -> SettingsScreen(st)
is Over.EditPad -> PadEditor(st, o.id)
Over.About -> AboutScreen(st) 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("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("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("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 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 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) 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. */ /** 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) val rev = mutableIntStateOf(0)
private fun bump() { rev.intValue++ } 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 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 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)") 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 ---------------- // ---------------- jar ----------------
fun addNote(text: String, day: LocalDate = LocalDate.now()) { 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 } 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. */ /** Plain-text backup of everything. */
fun exportText(): String = buildString { fun exportText(): String = buildString {
appendLine("SIMPLE MERCIES · exported ${LocalDate.now()}") appendLine("SIMPLE MERCIES · exported ${LocalDate.now()}")
@@ -118,6 +156,15 @@ class Store private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "mercies.d
appendLine() appendLine()
appendLine("KINDNESSES DONE") appendLine("KINDNESSES DONE")
for (k in kindDone()) appendLine("${k.day} ${Kindness.all[k.idx]}") 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 { companion object {
@@ -157,7 +157,9 @@ fun PrayerCard(st: AppState, p: Prayer) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.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) { 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"), "")) 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)) Spacer(Modifier.weight(1f))
SqButton("Save & send", icon = Icons.Filled.Send, enabled = text.isNotBlank()) { SqButton("Save & send", icon = Icons.Filled.Send, enabled = text.isNotBlank()) {
st.store.savePrayer(id, who, text); st.back() 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() } 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") Label("The jar")
ToggleRow("Shake to pull a mercy", p.shake, "Turn off if it goes off in your pocket") { p.shake = it } 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") Label("Your words")
Small("Everything you write stays on this phone. Make a copy now and then so it's never lost.") 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)) { Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -288,9 +295,9 @@ fun AboutScreen(st: AppState) {
Heading("Simple Mercies", size = 34) Heading("Simple Mercies", size = 34)
Text("Notice the good. Do some good.", color = l.faint, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 18.sp) 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)) 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" + "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) 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 _mornH = mutableIntStateOf(p.getInt("mornH", 7))
private val _mornM = mutableIntStateOf(p.getInt("mornM", 45)) private val _mornM = mutableIntStateOf(p.getInt("mornM", 45))
private val _shake = mutableStateOf(p.getBoolean("shake", true)) 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 _promptDay = mutableStateOf(p.getString("promptDay", "") ?: "")
private val _promptShift = mutableIntStateOf(p.getInt("promptShift", 0)) 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 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 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() } 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 */ /** 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 val promptShift: Int get() = if (_promptDay.value == java.time.LocalDate.now().toString()) _promptShift.intValue else 0