From 064c0a21a1289b1fa9d10c7789be0bd308634fca Mon Sep 17 00:00:00 2001 From: weishaupt <1+weishaupt@noreply.localhost> Date: Mon, 19 Oct 2026 15:54:10 +0000 Subject: [PATCH] Simple Mercies 1.2: X length warning and Daily Notepad --- .../space/bloggin/mercies/MainActivity.kt | 21 +- .../java/space/bloggin/mercies/Notepad.kt | 379 ++++++++++++++++++ .../main/java/space/bloggin/mercies/Store.kt | 51 ++- .../main/java/space/bloggin/mercies/Tabs.kt | 15 +- .../main/java/space/bloggin/mercies/Theme.kt | 6 + 5 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/space/bloggin/mercies/Notepad.kt diff --git a/app/src/main/java/space/bloggin/mercies/MainActivity.kt b/app/src/main/java/space/bloggin/mercies/MainActivity.kt index 834cd8b..fc203ba 100644 --- a/app/src/main/java/space/bloggin/mercies/MainActivity.kt +++ b/app/src/main/java/space/bloggin/mercies/MainActivity.kt @@ -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(null) + /** a heart flying out of a ticked to-do; changes every tick so a new one starts */ + var heart by mutableStateOf(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 = 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() } } } } diff --git a/app/src/main/java/space/bloggin/mercies/Notepad.kt b/app/src/main/java/space/bloggin/mercies/Notepad.kt new file mode 100644 index 0000000..7853efb --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Notepad.kt @@ -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() } + } + } + } +} diff --git a/app/src/main/java/space/bloggin/mercies/Store.kt b/app/src/main/java/space/bloggin/mercies/Store.kt index 4ccc1f1..1bbc218 100644 --- a/app/src/main/java/space/bloggin/mercies/Store.kt +++ b/app/src/main/java/space/bloggin/mercies/Store.kt @@ -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 = + 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 { diff --git a/app/src/main/java/space/bloggin/mercies/Tabs.kt b/app/src/main/java/space/bloggin/mercies/Tabs.kt index c03eebf..dc47e38 100644 --- a/app/src/main/java/space/bloggin/mercies/Tabs.kt +++ b/app/src/main/java/space/bloggin/mercies/Tabs.kt @@ -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) } } diff --git a/app/src/main/java/space/bloggin/mercies/Theme.kt b/app/src/main/java/space/bloggin/mercies/Theme.kt index 34e84c0..3bfc8a7 100644 --- a/app/src/main/java/space/bloggin/mercies/Theme.kt +++ b/app/src/main/java/space/bloggin/mercies/Theme.kt @@ -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