From 51aceb9f5ab678f9d40e24613f810230ae6896a2 Mon Sep 17 00:00:00 2001 From: weishaupt <1+weishaupt@noreply.localhost> Date: Mon, 19 Oct 2026 02:23:04 +0000 Subject: [PATCH] Add app/src/main/java/space/bloggin/mercies --- .../java/space/bloggin/mercies/Content.kt | 160 ++++++++++ .../main/java/space/bloggin/mercies/Daily.kt | 109 +++++++ .../main/java/space/bloggin/mercies/Jar.kt | 276 ++++++++++++++++++ .../space/bloggin/mercies/MainActivity.kt | 181 ++++++++++++ .../main/java/space/bloggin/mercies/Share.kt | 188 ++++++++++++ 5 files changed, 914 insertions(+) create mode 100644 app/src/main/java/space/bloggin/mercies/Content.kt create mode 100644 app/src/main/java/space/bloggin/mercies/Daily.kt create mode 100644 app/src/main/java/space/bloggin/mercies/Jar.kt create mode 100644 app/src/main/java/space/bloggin/mercies/MainActivity.kt create mode 100644 app/src/main/java/space/bloggin/mercies/Share.kt diff --git a/app/src/main/java/space/bloggin/mercies/Content.kt b/app/src/main/java/space/bloggin/mercies/Content.kt new file mode 100644 index 0000000..ecaf108 --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Content.kt @@ -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] +} diff --git a/app/src/main/java/space/bloggin/mercies/Daily.kt b/app/src/main/java/space/bloggin/mercies/Daily.kt new file mode 100644 index 0000000..345f776 --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Daily.kt @@ -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) {} + } +} diff --git a/app/src/main/java/space/bloggin/mercies/Jar.kt b/app/src/main/java/space/bloggin/mercies/Jar.kt new file mode 100644 index 0000000..68e9c50 --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Jar.kt @@ -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(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() } + } + } + } +} diff --git a/app/src/main/java/space/bloggin/mercies/MainActivity.kt b/app/src/main/java/space/bloggin/mercies/MainActivity.kt new file mode 100644 index 0000000..834cd8b --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/MainActivity.kt @@ -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 = 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) + } + } + } +} diff --git a/app/src/main/java/space/bloggin/mercies/Share.kt b/app/src/main/java/space/bloggin/mercies/Share.kt new file mode 100644 index 0000000..c546188 --- /dev/null +++ b/app/src/main/java/space/bloggin/mercies/Share.kt @@ -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(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) + } +} +