Add app/src/main/java/space/bloggin/mercies
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user