Add app/src/main/java/space/bloggin/mercies

This commit is contained in:
2026-10-19 02:23:04 +00:00
parent 07568d271f
commit 51aceb9f5a
5 changed files with 914 additions and 0 deletions
@@ -0,0 +1,160 @@
package space.bloggin.mercies
import java.time.LocalDate
object Kindness {
val all = listOf(
"Text someone you haven't talked to in a while, just to say you thought of them.",
"Hold the door for the person behind you, even if they're a little far away.",
"Leave a good review for a small local business you like.",
"Thank a coworker for something specific they did.",
"Let someone go ahead of you in line.",
"Write a short note to a teacher who made a difference for you.",
"Bring in your neighbor's trash can after pickup.",
"Pay a sincere compliment to a stranger.",
"Call an older relative and let them do most of the talking.",
"Put your phone away during a meal with someone.",
"Tip a little more than usual today.",
"Pick up a piece of litter you'd normally walk past.",
"Ask someone how they're really doing, and wait for the real answer.",
"Send a photo of a good memory to the person who's in it.",
"Thank the cashier by name if they wear a tag.",
"Return a shopping cart someone else left in the lot.",
"Forgive a small thing without mentioning it.",
"Make a double batch of supper and share half.",
"Leave an encouraging sticky note where someone will find it.",
"Let another driver merge in front of you, and wave.",
"Tell your spouse one thing you admire about them.",
"Donate a coat or blanket you aren't using.",
"Put a few cans in the food pantry box.",
"Listen to a whole story without checking your phone.",
"Send a thank-you message to someone who helped you this year.",
"Offer to take a photo for a group trying to get everyone in.",
"Water a neighbor's flowers or plants.",
"Say something kind about someone who isn't in the room.",
"Leave a quarter in a gumball machine or a parking meter for the next person.",
"Check on someone who's been quiet lately.",
"Share an umbrella, a pen, or a charger.",
"Write down three things you appreciate about a family member, then tell them.",
"Let someone else have the last piece.",
"Smile at the next five people you pass.",
"Clean up a mess at home that isn't yours, without saying anything.",
"Send flowers or a card to someone who's grieving.",
"Offer to run an errand for someone who's busy or unwell.",
"Thank a delivery driver, mail carrier, or trash collector.",
"Give a stranger directions with a little extra care.",
"Bring coffee or a treat for the people you work with.",
"Shovel, sweep, or mow a little past your own line.",
"Write a positive comment on something a friend made or shared.",
"Leave a book you loved somewhere for someone else to find.",
"Say yes to a small favor you'd normally put off.",
"Teach someone a skill you have, even a small one.",
"Apologize for something you've been meaning to.",
"Give someone your full attention for ten minutes.",
"Hand-write a letter and mail it.",
"Compliment a parent on how they're raising their kids.",
"Help someone carry something heavy.",
"Let a friend vent without trying to fix it.",
"Thank a veteran, a nurse, a lineman, or a first responder.",
"Buy the coffee for the person behind you.",
"Tell a kid you're proud of them for something specific.",
"Leave a generous note with your tip.",
"Pray for someone by name, or simply hold them in your thoughts.",
"Remember someone's birthday this month and plan something small.",
"Bake something and give it away.",
"Visit someone who lives alone.",
"Pick up the tab for a small thing a friend needs.",
"Speak gently to someone who is frustrated with you.",
"Write a review for an app or book made by a small creator.",
"Fix something that's been broken for someone else.",
"Give a sincere thank-you to a customer service worker.",
"Make room at your table for someone new.",
"Leave the last parking spot near the door for someone else.",
"Share credit for something you did together.",
"Send a funny video to someone who needs a laugh.",
"Offer your seat to someone who looks tired.",
"Leave a little extra food or water out for a neighborhood pet.",
"Thank your pastor, coach, or group leader.",
"Tell an old friend what their friendship has meant to you.",
"Give away something you've been saving for 'someday'.",
"Help a coworker finish something so they can go home on time.",
"Ask an older person to tell you about their life.",
"Hold your tongue instead of having the last word.",
"Clean out your car and give a ride to someone who needs one.",
"Leave a kind note in a library book.",
"Make a playlist for someone.",
"Hang up a coat, put away the dishes, take out the trash before you're asked.",
"Tell a stranger they did a good job.",
"Say thank you twice today when you'd normally say it once.",
"Learn someone's name and use it.",
"Pick up groceries for a neighbor while you're already out.",
"Give a sincere compliment about someone's work to their boss.",
"Be patient with a slow driver or a slow line today.",
"Send a care package to someone far away.",
"Bring a meal to a family with a new baby or a hard week.",
"Leave a thank-you note for your housekeeping, maintenance, or custodial staff.",
"Look up from your phone and say hello to someone at the store.",
"Tell someone they were right about something.",
"Invite someone who's often left out.",
"Donate blood or sign up to.",
"Leave a few dollars where someone in need will find it.",
"Share a skill or a tool with a neighbor.",
"Write down a prayer or kind wish for someone and send it to them.",
"Help a child with homework or a project, patiently.",
"Be the first to say good morning.",
"Thank someone who said no to you kindly.",
"Give an honest, gentle word of encouragement to someone trying something new.",
"Tidy a shared space at work or church.",
"Let someone else choose the restaurant or the show.",
"Leave a note on a windshield that says 'Hope your day is going well.'",
"Send a message to someone you've been meaning to reconnect with.",
"Spend five quiet minutes being thankful for your home.",
"Bring in a treat for your kids' teachers or bus driver.",
"Pay a compliment about character, not looks.",
"Help someone who's lost in a parking lot or a store.",
"Carry an extra snack to give away.",
"Let someone talk about the thing they love most.",
"Make the bed, start the coffee, or do a chore for someone in your home.",
"Share a meal with someone eating alone.",
"Tell someone you're thinking of them on a hard anniversary.",
"Speak well of your spouse in front of others.",
"Leave your campsite, table, or seat cleaner than you found it.",
"Send a note to your parents or grandparents, or to someone who was like one.",
"Cover a small cost for a friend without making a fuss about it.",
"Notice someone working hard and tell them so.",
"Give someone the benefit of the doubt today.",
"Ask, 'Is there anything I can do?' and mean it.",
"Rest, and let someone else help you. Receiving kindness is a kindness too.",
)
fun indexFor(date: LocalDate, shift: Int = 0): Int {
val order = all.indices.shuffled(java.util.Random(date.year.toLong() * 31L + 7L))
return order[(date.dayOfYear - 1 + shift * 53).mod(order.size)]
}
}
object Prompts {
val all = listOf(
"What made you smile today?",
"Who did something kind for you lately?",
"What's something small that went right?",
"What are you glad you didn't miss today?",
"What sound, smell, or taste was good today?",
"Who would you like to thank?",
"What did you learn today?",
"What's a comfort you usually take for granted?",
"What surprised you in a good way?",
"What made home feel like home today?",
"What was the best part of your work today?",
"What did someone say that stuck with you?",
"What in nature caught your eye?",
"What are you looking forward to?",
"What hard thing turned out okay?",
"What made you laugh?",
"What did your body let you do today?",
"Who are you glad is in your life?",
"What's one thing that was easier than you expected?",
"What answered prayer or happy coincidence did you notice?",
)
fun forDate(date: LocalDate) = all[(date.dayOfYear + date.year) % all.size]
}
@@ -0,0 +1,109 @@
package space.bloggin.mercies
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import java.time.LocalDate
import java.util.Calendar
object Daily {
private const val CH = "mercies"
fun open(ctx: Context, tab: String, code: Int): PendingIntent {
val i = Intent(ctx, MainActivity::class.java).apply {
action = "space.bloggin.mercies.OPEN_$tab"; putExtra("tab", tab)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return PendingIntent.getActivity(ctx, code, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun alarm(ctx: Context, which: String, on: Boolean, h: Int, m: Int, code: Int) {
val am = ctx.getSystemService(AlarmManager::class.java)
val pi = PendingIntent.getBroadcast(ctx, code, Intent(ctx, DailyReceiver::class.java).putExtra("which", which),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
am.cancel(pi)
if (!on) return
val cal = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, h); set(Calendar.MINUTE, m); set(Calendar.SECOND, 0)
if (timeInMillis <= System.currentTimeMillis()) add(Calendar.DAY_OF_YEAR, 1)
}
am.setInexactRepeating(AlarmManager.RTC_WAKEUP, cal.timeInMillis, AlarmManager.INTERVAL_DAY, pi)
}
fun schedule(ctx: Context) {
val p = Prefs.get(ctx)
alarm(ctx, "eve", p.eve, p.eveH, p.eveM, 11)
alarm(ctx, "morn", true, if (p.morn) p.mornH else 0, if (p.morn) p.mornM else 3, 12) // also refreshes the widget each day
}
fun notify(ctx: Context, which: String) {
if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
val nm = ctx.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(NotificationChannel(CH, "Gentle reminders", NotificationManager.IMPORTANCE_DEFAULT))
val n = if (which == "eve") {
val alreadyToday = Store.get(ctx).notesOn(LocalDate.now()).isNotEmpty()
if (alreadyToday) return
NotificationCompat.Builder(ctx, CH).setSmallIcon(R.drawable.ic_notify)
.setContentTitle("The jar is open")
.setContentText(Prompts.forDate(LocalDate.now()))
.setContentIntent(open(ctx, "jar", 21)).setAutoCancel(true).build()
} else {
val k = Store.get(ctx).kindToday()
NotificationCompat.Builder(ctx, CH).setSmallIcon(R.drawable.ic_notify)
.setContentTitle("Today's kindness")
.setContentText(Kindness.all[k.idx])
.setStyle(NotificationCompat.BigTextStyle().bigText(Kindness.all[k.idx]))
.setContentIntent(open(ctx, "kind", 22)).setAutoCancel(true).build()
}
nm.notify(if (which == "eve") 1 else 2, n)
}
}
class DailyReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val which = intent.getStringExtra("which") ?: return
MercyWidget.refresh(ctx)
val p = Prefs.get(ctx)
if ((which == "eve" && p.eve) || (which == "morn" && p.morn)) Daily.notify(ctx, which)
}
}
class BootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) { Daily.schedule(ctx); MercyWidget.refresh(ctx) }
}
class MercyWidget : AppWidgetProvider() {
override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) = update(ctx, mgr, ids)
companion object {
fun update(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
val store = Store.get(ctx)
val k = store.kindToday()
val count = store.noteCount()
for (id in ids) {
val rv = RemoteViews(ctx.packageName, R.layout.widget_mercy)
rv.setTextViewText(R.id.w_text, Kindness.all[k.idx])
rv.setTextViewText(R.id.w_foot, (if (k.status == 1) "Done ❀ " else "") + if (count == 1) "1 mercy in the jar" else "$count mercies in the jar")
rv.setOnClickPendingIntent(R.id.w_root, Daily.open(ctx, "kind", 23))
mgr.updateAppWidget(id, rv)
}
}
fun refresh(ctx: Context) = try {
val mgr = AppWidgetManager.getInstance(ctx)
update(ctx, mgr, mgr.getAppWidgetIds(ComponentName(ctx, MercyWidget::class.java)))
} catch (_: Exception) {}
}
}
@@ -0,0 +1,276 @@
package space.bloggin.mercies
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.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.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.sqrt
private val slipColors = listOf(
Color(0xFFF3D6D6), Color(0xFFF6E7C1), Color(0xFFDCE7CF), Color(0xFFD7E1EE), Color(0xFFE8DAF0), Color(0xFFFBF4E6), Color(0xFFF2DCC8)
)
/** A glass jar with one folded slip of paper per note (up to 120 drawn). */
@Composable
fun JarDrawing(count: Int, modifier: Modifier = Modifier, wobble: Float = 0f) {
val l = LocalLook.current
Canvas(modifier.graphicsLayer { rotationZ = wobble; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.95f) }) {
val w = size.width; val h = size.height
val jw = minOf(w * 0.62f, h * 0.62f); val jx = (w - jw) / 2f
val neckW = jw * 0.72f; val neckX = (w - neckW) / 2f
val lidH = h * 0.09f; val top = h * 0.05f
val neckTop = top + lidH; val neckH = h * 0.06f
val bodyTop = neckTop + neckH; val bodyBot = h * 0.97f
val glass = Path().apply {
moveTo(neckX, neckTop)
lineTo(neckX, neckTop + neckH)
cubicTo(jx - jw * 0.02f, bodyTop + 6f, jx, bodyTop + jw * 0.05f, jx, bodyTop + jw * 0.18f)
lineTo(jx, bodyBot - jw * 0.12f)
quadraticTo(jx, bodyBot, jx + jw * 0.12f, bodyBot)
lineTo(jx + jw * 0.88f, bodyBot)
quadraticTo(jx + jw, bodyBot, jx + jw, bodyBot - jw * 0.12f)
lineTo(jx + jw, bodyTop + jw * 0.18f)
cubicTo(jx + jw, bodyTop + jw * 0.05f, jx + jw * 1.02f, bodyTop + 6f, neckX + neckW, neckTop + neckH)
lineTo(neckX + neckW, neckTop)
close()
}
drawPath(glass, Color.White.copy(alpha = if (l.dark) 0.06f else 0.22f))
// slips, filling from the bottom
val n = count.coerceAtMost(120)
val slipW = jw * 0.17f; val slipH = slipW * 0.5f
val perRow = 6
val rnd = java.util.Random(42)
for (i in 0 until n) {
val row = i / perRow; val col = i % perRow
val inner = jw * 0.86f
val x = jx + jw * 0.07f + (col + 0.5f) * inner / perRow + (rnd.nextFloat() - 0.5f) * slipW * 0.5f + (if (row % 2 == 1) inner / perRow * 0.4f else 0f) - inner / perRow * 0.2f
val y = bodyBot - slipH * 0.9f - row * slipH * 0.72f + (rnd.nextFloat() - 0.5f) * slipH * 0.4f
if (y < bodyTop + slipH) break
val c = slipColors[rnd.nextInt(slipColors.size)]
rotate(degrees = (rnd.nextFloat() - 0.5f) * 70f, pivot = Offset(x, y)) {
drawRect(c, topLeft = Offset(x - slipW / 2, y - slipH / 2), size = Size(slipW, slipH))
drawRect(Color(0x33000000), topLeft = Offset(x - slipW / 2, y - slipH / 2), size = Size(slipW, slipH), style = Stroke(1.2f))
drawLine(Color(0x22000000), Offset(x, y - slipH / 2), Offset(x, y + slipH / 2), 1f)
}
}
// glass outline and shine
drawPath(glass, l.ink.copy(alpha = 0.55f), style = Stroke(width = 3.dp.toPx()))
drawLine(Color.White.copy(alpha = 0.55f), Offset(jx + jw * 0.1f, bodyTop + jw * 0.25f), Offset(jx + jw * 0.1f, bodyBot - jw * 0.2f), 5.dp.toPx())
drawLine(Color.White.copy(alpha = 0.3f), Offset(jx + jw * 0.17f, bodyTop + jw * 0.3f), Offset(jx + jw * 0.17f, bodyTop + jw * 0.55f), 3.dp.toPx())
// tin lid with ridges
val lidX = neckX - jw * 0.05f; val lidW = neckW + jw * 0.1f
drawRoundRect(Color(0xFFB8A27A), Offset(lidX, top), Size(lidW, lidH), CornerRadius(4.dp.toPx()))
for (k in 1 until 9) {
val lx = lidX + lidW * k / 9f
drawLine(Color(0x55000000), Offset(lx, top + 3f), Offset(lx, top + lidH - 3f), 1.5f)
}
drawRoundRect(l.ink.copy(alpha = 0.5f), Offset(lidX, top), Size(lidW, lidH), CornerRadius(4.dp.toPx()), style = Stroke(2.dp.toPx()))
// twine bow
drawLine(l.accent.copy(alpha = 0.8f), Offset(neckX, neckTop + neckH * 0.5f), Offset(neckX + neckW, neckTop + neckH * 0.5f), 3.dp.toPx())
drawCircle(l.accent.copy(alpha = 0.8f), radius = 5.dp.toPx(), center = Offset(neckX + neckW * 0.78f, neckTop + neckH * 0.5f))
}
}
/** Calls [onShake] when the phone is shaken. */
@Composable
fun ShakeListener(enabled: Boolean, onShake: () -> Unit) {
val ctx = LocalContext.current
val cb by rememberUpdatedState(onShake)
DisposableEffect(enabled) {
val sm = ctx.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val acc = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
var last = 0L
val l = object : SensorEventListener {
override fun onSensorChanged(e: SensorEvent) {
val g = sqrt(e.values[0] * e.values[0] + e.values[1] * e.values[1] + e.values[2] * e.values[2]) / SensorManager.GRAVITY_EARTH
val now = System.currentTimeMillis()
if (g > 2.3f && now - last > 1500) { last = now; cb() }
}
override fun onAccuracyChanged(s: Sensor?, a: Int) {}
}
if (enabled && acc != null) sm.registerListener(l, acc, SensorManager.SENSOR_DELAY_UI)
onDispose { sm.unregisterListener(l) }
}
}
private val dayFmt = DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy")
private val shortFmt = DateTimeFormatter.ofPattern("MMMM d, yyyy")
@Composable
fun JarTab(st: AppState) {
val l = LocalLook.current
val rev = st.store.rev.intValue
val count = remember(rev) { st.store.noteCount() }
val todays = remember(rev) { st.store.notesOn(LocalDate.now()) }
var text by remember { mutableStateOf("") }
var pulled by remember { mutableStateOf<JarNote?>(null) }
val wobble = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
fun shake() {
if (count == 0) { st.toast("The jar is empty. Add your first mercy below."); return }
scope.launch {
for (a in listOf(-9f, 8f, -6f, 4f, -2f, 0f)) wobble.animateTo(a, tween(90))
pulled = st.store.randomNote(pulled?.id ?: -1)
}
}
ShakeListener(st.prefs.shake && pulled == null && st.overs.isEmpty()) { shake() }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally) {
Header(st, "Simple Mercies", LocalDate.now().format(dayFmt))
Column(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(horizontal = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
JarDrawing(count, Modifier.fillMaxWidth().height(250.dp).clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { shake() }, wobble.value)
Text(
when (count) { 0 -> "An empty jar, waiting"; 1 -> "1 mercy in the jar"; else -> "$count mercies in the jar" },
color = l.ink, fontFamily = Fonts.fell, fontSize = 20.sp
)
Small(if (count == 0) "Write something good below to start" else "Shake your phone, or tap the jar, to pull one out", Modifier.padding(top = 2.dp), center = true)
Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Shake the jar", icon = Icons.Filled.Vibration) { shake() }
SqButton("Look inside", icon = Icons.Filled.MenuBook) { st.browsing = true }
}
Spacer(Modifier.height(18.dp))
Panel(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(Prompts.forDate(LocalDate.now()), color = l.ink, fontFamily = Fonts.fell, fontStyle = FontStyle.Italic, fontSize = 22.sp, lineHeight = 28.sp)
Spacer(Modifier.height(10.dp))
Field(text, { text = it }, "One good thing from today…", Modifier.fillMaxWidth(), minHeight = 90.dp)
Spacer(Modifier.height(10.dp))
SqButton("Drop it in the jar", icon = Icons.Filled.Add, filled = true, enabled = text.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
st.store.addNote(text); text = ""
scope.launch { for (a in listOf(3f, -3f, 1.5f, 0f)) wobble.animateTo(a, tween(80)) }
}
if (todays.isNotEmpty()) {
Label("Today")
todays.forEach { n ->
Row(Modifier.fillMaxWidth().clickable { st.open(Over.EditNote(n)) }.padding(vertical = 5.dp)) {
Text("❀ ", color = l.accent, fontSize = 16.sp)
Text(n.text, color = l.ink, fontFamily = Fonts.body, fontSize = 18.sp)
}
}
}
}
}
Spacer(Modifier.height(14.dp))
KindMini(st)
Spacer(Modifier.height(30.dp))
}
}
// the slip that came out of the jar
AnimatedVisibility(pulled != null, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)).clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { pulled = null })
}
AnimatedVisibility(pulled != null, Modifier.align(Alignment.Center), enter = scaleIn(initialScale = 0.4f) + fadeIn(), exit = fadeOut()) {
val n = pulled
if (n != null) {
Column(
Modifier.padding(26.dp).widthIn(max = 520.dp).background(Color(0xFFFBF5E8)).padding(26.dp)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("From " + n.day.format(shortFmt), color = Color(0xFFA0525A), fontFamily = Fonts.fellSc, fontSize = 17.sp)
Spacer(Modifier.height(12.dp))
Text(n.text, color = Color(0xFF3A2E26), fontFamily = Fonts.body, fontStyle = FontStyle.Italic, fontSize = 24.sp, lineHeight = 32.sp, textAlign = TextAlign.Center)
Spacer(Modifier.height(16.dp))
CompositionLocalProvider(LocalLook provides Looks.all[0]) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Another", icon = Icons.Filled.Refresh) { shake() }
SqButton("Send", icon = Icons.Filled.Share) {
val card = Card("A simple mercy", n.text, "Simple Mercies")
pulled = null; st.open(Over.ShareCard(card, ""))
}
SqButton("Close", filled = true) { pulled = null }
}
}
}
}
}
}
}
@Composable
fun BrowseJar(st: AppState) {
val l = LocalLook.current
val rev = st.store.rev.intValue
val notes = remember(rev) { st.store.allNotes() }
val days = remember(rev) { st.store.daysWithNotes() }
val byMonth = remember(notes) { notes.groupBy { it.day.withDayOfMonth(1) } }
Column(Modifier.fillMaxSize()) {
Header(st, "Inside the jar", "${notes.size} mercies over $days day" + (if (days == 1) "" else "s"), back = { st.browsing = false })
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
if (notes.isEmpty()) item { Small("Nothing in the jar yet.", Modifier.padding(30.dp), center = true) }
byMonth.forEach { (month, list) ->
item(key = "m$month") {
Label(month.format(DateTimeFormatter.ofPattern("MMMM yyyy")), Modifier.widthIn(max = 640.dp).fillMaxWidth())
}
items(list, key = { it.id }) { n ->
Panel(Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(bottom = 8.dp).clickable { st.open(Over.EditNote(n)) }) {
Column(Modifier.padding(14.dp)) {
Text(n.day.format(DateTimeFormatter.ofPattern("EEE, MMM d")), color = l.accent, fontFamily = Fonts.fellSc, fontSize = 15.sp)
Text(n.text, color = l.ink, fontFamily = Fonts.body, fontSize = st.prefs.size.sp, lineHeight = (st.prefs.size * 1.35f).sp)
}
}
}
}
item { Spacer(Modifier.height(30.dp)) }
}
}
}
@Composable
fun NoteEditor(st: AppState, note: JarNote) {
var text by remember { mutableStateOf(note.text) }
Overlay(st, note.day.format(shortFmt)) {
Column(Modifier.padding(16.dp)) {
Field(text, { text = it }, "One good thing…", Modifier.fillMaxWidth(), minHeight = 140.dp)
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SqButton("Delete", icon = Icons.Filled.Delete) { st.store.deleteNote(note.id); st.back() }
SqButton("Send", icon = Icons.Filled.Share) { st.back(); st.open(Over.ShareCard(Card("A simple mercy", text, "Simple Mercies"), "")) }
Spacer(Modifier.weight(1f))
SqButton("Save", icon = Icons.Filled.Check, filled = true) { st.store.updateNote(note.id, text); st.back() }
}
}
}
}
@@ -0,0 +1,181 @@
package space.bloggin.mercies
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
sealed class Over {
data class ShareCard(val card: Card, val caption: String) : Over()
data class EditPrayer(val id: Long?) : Over()
data class Answer(val id: Long) : Over()
data class EditNote(val note: JarNote) : 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 */
var tab by mutableIntStateOf(0)
val overs: SnapshotStateList<Over> = mutableStateListOf()
var browsing by mutableStateOf(false)
fun open(o: Over) { overs.add(o) }
fun back(): Boolean {
if (overs.isNotEmpty()) { overs.removeAt(overs.lastIndex); return true }
if (browsing) { browsing = false; return true }
if (tab != 0) { tab = 0; return true }
return false
}
fun toast(s: String) = Toast.makeText(ctx, s, Toast.LENGTH_SHORT).show()
}
class MainActivity : ComponentActivity() {
lateinit var state: AppState
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
state = AppState(this, Store.get(this), Prefs.get(this))
handle(intent)
Daily.schedule(this)
MercyWidget.refresh(this)
setContent {
val l = state.prefs.active
LaunchedEffect(l.dark) {
enableEdgeToEdge(
statusBarStyle = if (l.dark) SystemBarStyle.dark(0) else SystemBarStyle.light(0, 0),
navigationBarStyle = if (l.dark) SystemBarStyle.dark(0) else SystemBarStyle.light(0, 0),
)
}
val scheme = if (l.dark) darkColorScheme(primary = l.ink, surface = l.panel, onSurface = l.ink) else lightColorScheme(primary = l.ink, surface = l.panel, onSurface = l.ink)
MaterialTheme(colorScheme = scheme) {
CompositionLocalProvider(LocalLook provides l) {
BackHandler(enabled = state.overs.isNotEmpty() || state.browsing || state.tab != 0) { state.back() }
App(state)
}
}
}
}
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); handle(intent) }
private fun handle(i: Intent?) {
when (i?.getStringExtra("tab")) {
"jar" -> { state.overs.clear(); state.tab = 0 }
"kind" -> { state.overs.clear(); state.tab = 1 }
"prayers" -> { state.overs.clear(); state.tab = 2 }
}
}
override fun onPause() { super.onPause(); MercyWidget.refresh(this) }
}
@Composable
fun App(st: AppState) {
val l = LocalLook.current
Box(Modifier.fillMaxSize()) {
LookFill(l)
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f)) {
when (st.tab) {
0 -> if (st.browsing) BrowseJar(st) else JarTab(st)
1 -> KindTab(st)
else -> PrayersTab(st)
}
}
TabBar(st)
}
st.overs.lastOrNull()?.let { o ->
when (o) {
is Over.ShareCard -> ShareScreen(st, o.card, o.caption)
is Over.EditPrayer -> PrayerEditor(st, o.id)
is Over.Answer -> AnswerSheet(st, o.id)
is Over.EditNote -> NoteEditor(st, o.note)
Over.Settings -> SettingsScreen(st)
Over.About -> AboutScreen(st)
}
}
}
}
@Composable
fun TabBar(st: AppState) {
val l = LocalLook.current
Column(Modifier.fillMaxWidth().background(l.panel.copy(alpha = 1f))) {
Rule(alpha = 0.4f)
Row(Modifier.fillMaxWidth().navigationBarsPadding().height(62.dp)) {
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() }
}
}
}
@Composable
fun TabItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
val l = LocalLook.current
Column(
modifier.fillMaxHeight().background(if (selected) l.ink.copy(alpha = 0.08f) else Color.Transparent).clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center
) {
if (selected) Box(Modifier.width(36.dp).height(2.dp).background(l.accent))
Spacer(Modifier.height(5.dp))
Icon(icon, label, tint = if (selected) l.accent else l.ink, modifier = Modifier.size(24.dp))
Text(label, color = if (selected) l.accent else l.ink, fontFamily = Fonts.fellSc, fontSize = 14.sp)
}
}
/** Top header shared by the tabs. */
@Composable
fun Header(st: AppState, title: String, sub: String? = null, back: (() -> Unit)? = null) {
val l = LocalLook.current
Row(Modifier.fillMaxWidth().statusBarsPadding().padding(start = 6.dp, end = 4.dp, top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
if (back != null) IconBtn(Icons.Filled.ArrowBack, "Back", onClick = back) else Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Heading(title, size = 28)
if (sub != null) Text(sub, color = l.faint, fontFamily = Fonts.fell, fontSize = 15.sp, fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
}
IconBtn(if (st.prefs.night) Icons.Filled.LightMode else Icons.Filled.DarkMode, "Night") { st.prefs.night = !st.prefs.night }
IconBtn(Icons.Filled.Settings, "Settings") { st.open(Over.Settings) }
}
}
/** A full-screen layer with a back arrow. */
@Composable
fun Overlay(st: AppState, title: String, content: @Composable ColumnScope.() -> Unit) {
val l = LocalLook.current
Box(Modifier.fillMaxSize().clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}) {
LookFill(l)
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().background(l.panel.copy(alpha = 1f)).statusBarsPadding().height(54.dp).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) {
IconBtn(Icons.Filled.ArrowBack, "Back") { st.back() }
Heading(title, Modifier.padding(start = 4.dp), size = 22)
}
Rule(alpha = 0.4f)
Column(Modifier.fillMaxSize().navigationBarsPadding().imePadding(), horizontalAlignment = Alignment.CenterHorizontally) {
Column(Modifier.widthIn(max = 720.dp).fillMaxSize(), content = content)
}
}
}
}
@@ -0,0 +1,188 @@
package space.bloggin.mercies
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.core.content.res.ResourcesCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
enum class Shape(val label: String, val w: Int, val h: Int) {
SQUARE("Square", 1080, 1080), WIDE("Wide 16:9", 1600, 900), STORY("Story 9:16", 1080, 1920)
}
data class Card(val title: String, val body: String, val footer: String)
object ShareKit {
fun render(ctx: Context, card: Card, look: Look, shape: Shape): Bitmap {
val w = shape.w; val h = shape.h
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
val src = BitmapFactory.decodeResource(ctx.resources, look.image)
val scale = maxOf(w.toFloat() / src.width, h.toFloat() / src.height)
val sw = (w / scale).toInt(); val sh = (h / scale).toInt()
val sx = (src.width - sw) / 2; val sy = (src.height - sh) / 2
c.drawBitmap(src, Rect(sx, sy, sx + sw, sy + sh), Rect(0, 0, w, h), Paint(Paint.FILTER_BITMAP_FLAG))
// the tall background already has flowers in its corners; wider shapes crop them away, so add them back
if (h < w * 1.5f) {
val s = (minOf(w, h) * 0.42f).toInt()
val tl = BitmapFactory.decodeResource(ctx.resources, if (look.dark) R.drawable.sprig_dusk_tl else R.drawable.sprig_day_tl)
val br = BitmapFactory.decodeResource(ctx.resources, if (look.dark) R.drawable.sprig_dusk_br else R.drawable.sprig_day_br)
val fp = Paint(Paint.FILTER_BITMAP_FLAG)
c.drawBitmap(tl, null, Rect(0, 0, s, s), fp)
c.drawBitmap(br, null, Rect(w - s, h - s, w, h), fp)
}
val ink = look.ink.toArgb(); val accent = look.accent.toArgb(); val faint = look.faint.toArgb()
val m = minOf(w, h) * 0.11f
val frame = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = look.leaf.toArgb(); alpha = 140; strokeWidth = 2.5f }
c.drawRect(RectF(m * 0.42f, m * 0.42f, w - m * 0.42f, h - m * 0.42f), frame)
val fellSc = ResourcesCompat.getFont(ctx, R.font.imfellenglishsc_regular) ?: Typeface.SERIF
val bodyIt = ResourcesCompat.getFont(ctx, R.font.ebgaramond_italic) ?: Typeface.SERIF
val boxW = (w - m * 2).toInt()
val tp = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { typeface = fellSc; color = accent; textSize = minOf(w, h) * 0.058f; letterSpacing = 0.04f }
val titleL = StaticLayout.Builder.obtain(card.title, 0, card.title.length, tp, boxW).setAlignment(Layout.Alignment.ALIGN_CENTER).build()
val bp = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { typeface = bodyIt; color = ink }
var size = minOf(w, h) * (if (h > w * 1.5f) 0.085f else 0.068f)
val maxH = h - m * 2 - titleL.height - minOf(w, h) * 0.16f
var bodyL: StaticLayout
while (true) {
bp.textSize = size
bodyL = StaticLayout.Builder.obtain(card.body, 0, card.body.length, bp, boxW).setAlignment(Layout.Alignment.ALIGN_CENTER).setLineSpacing(0f, 1.2f).build()
if (bodyL.height <= maxH || size < 18f) break
size *= 0.94f
}
val gap = minOf(w, h) * 0.05f
val total = titleL.height + gap + bodyL.height + gap * 2
var y = (h - total) / 2f
c.save(); c.translate(m, y); titleL.draw(c); c.restore()
y += titleL.height + gap
c.save(); c.translate(m, y); bodyL.draw(c); c.restore()
y += bodyL.height + gap
// little flower
val cx = w / 2f
val lp = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = look.leaf.toArgb(); strokeWidth = 2f; alpha = 170 }
c.drawLine(cx - gap * 2.4f, y, cx - gap * 0.6f, y, lp); c.drawLine(cx + gap * 0.6f, y, cx + gap * 2.4f, y, lp)
val pp = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = accent; alpha = 200 }
for (i in 0 until 5) {
val a = Math.toRadians(i * 72.0 - 90)
c.drawCircle(cx + (Math.cos(a) * gap * 0.22).toFloat(), y + (Math.sin(a) * gap * 0.22).toFloat(), gap * 0.16f, pp)
}
c.drawCircle(cx, y, gap * 0.1f, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFE0C070.toInt() })
if (card.footer.isNotBlank()) {
val fp = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { typeface = fellSc; color = faint; textSize = minOf(w, h) * 0.032f; textAlign = Paint.Align.CENTER }
c.drawText(card.footer, cx, h - m * 0.75f, fp)
}
return bmp
}
fun shareImage(ctx: Context, bmp: Bitmap, name: String, text: String) {
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val f = File(dir, name.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifBlank { "mercy" } + ".png")
f.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
val uri = FileProvider.getUriForFile(ctx, ctx.packageName + ".files", f)
val i = Intent(Intent.ACTION_SEND).apply {
type = "image/png"; putExtra(Intent.EXTRA_STREAM, uri); if (text.isNotBlank()) putExtra(Intent.EXTRA_TEXT, text)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
ctx.startActivity(Intent.createChooser(i, "Send").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
fun shareText(ctx: Context, text: String, subject: String = "") {
val i = Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, text); if (subject.isNotBlank()) putExtra(Intent.EXTRA_SUBJECT, subject) }
ctx.startActivity(Intent.createChooser(i, "Send").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
fun save(ctx: Context, bmp: Bitmap, name: String): Boolean = try {
val values = android.content.ContentValues().apply {
put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, name.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-') + ".png")
put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
if (android.os.Build.VERSION.SDK_INT >= 29) put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, android.os.Environment.DIRECTORY_PICTURES + "/Simple Mercies")
}
val uri = ctx.contentResolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
ctx.contentResolver.openOutputStream(uri)!!.use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
true
} catch (e: Exception) { false }
}
/** Full-screen preview: pick a shape and a linen, then send. */
@Composable
fun ShareScreen(st: AppState, card: Card, caption: String) {
val l = LocalLook.current
val ctx = LocalContext.current
var shape by remember { mutableStateOf(Shape.SQUARE) }
var look by remember { mutableIntStateOf(if (st.prefs.night) 3 else st.prefs.look) }
var withFooter by remember { mutableStateOf(true) }
var bmp by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(shape, look, withFooter) {
delay(80)
bmp = withContext(Dispatchers.Default) { ShareKit.render(ctx, card.copy(footer = if (withFooter) card.footer else ""), Looks.all[look], shape) }
}
Overlay(st, "Send as a picture") {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Box(Modifier.fillMaxWidth().aspectRatio(shape.w / shape.h.toFloat()).heightIn(max = 520.dp).border(1.dp, l.faint.copy(alpha = 0.4f))) {
bmp?.let { Image(it.asImageBitmap(), "Preview", Modifier.fillMaxSize(), contentScale = ContentScale.Fit) }
}
Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SqButton("Save", icon = Icons.Filled.Download, modifier = Modifier.weight(1f)) {
bmp?.let { st.toast(if (ShareKit.save(ctx, it, card.title)) "Saved to Pictures" else "Couldn't save") }
}
SqButton("Send", icon = Icons.Filled.Share, filled = true, modifier = Modifier.weight(1f)) { bmp?.let { ShareKit.shareImage(ctx, it, card.title, caption) } }
}
Label("Shape")
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Shape.entries.forEach { s -> Chip(s.label, shape == s) { shape = s } } }
Label("Background")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Looks.all.forEachIndexed { i, lk -> Swatch(lk, look == i, Modifier.weight(1f)) { look = i } }
}
ToggleRow("Show “Simple Mercies” at the bottom", withFooter) { withFooter = it }
Spacer(Modifier.height(30.dp))
}
}
}
@Composable
fun Chip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val l = LocalLook.current
Box(modifier.border(1.dp, l.ink.copy(alpha = 0.7f)).then(if (selected) Modifier.background(l.ink) else Modifier).clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 8.dp), contentAlignment = Alignment.Center) {
Text(text, color = if (selected) onInk(l) else l.ink, fontFamily = Fonts.ui, fontSize = 14.sp, maxLines = 1)
}
}