Simple Mercies 1.2: X length warning and Daily Notepad
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user