Skip to content

Instantly share code, notes, and snippets.

@insearching
Created June 10, 2025 00:03
Show Gist options
  • Save insearching/3de4021c009ef57f30a91ae5bc97ce0c to your computer and use it in GitHub Desktop.
Save insearching/3de4021c009ef57f30a91ae5bc97ce0c to your computer and use it in GitHub Desktop.
package com.google.voice.plchallangesactivity.presentation.egg_hunt
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.voice.plchallangesactivity.R
import com.google.voice.plchallangesactivity.ui.theme.PlChallengesTheme
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
private val titles = listOf<String>(
"Behind the TV stand",
"Behind the TV stand",
"Under the picnic table",
"Inside the birdhouse",
"Behind the garden bushes",
"Inside the flower pot",
"In the mailbox",
"In the kitchen pantry",
"Inside the flower pot",
"Inside the bedroom",
)
private const val EASTER_KEY = "easter_key"
@Composable
fun EggHunt(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val easterFacts = remember {
context.resources.getStringArray(R.array.easter_facts).toList()
}
var eggsFound by remember { mutableIntStateOf(0) }
val scope = rememberCoroutineScope()
var showEasterFactDialog by remember { mutableStateOf(false) }
var showEggRollAwayDialog by remember { mutableStateOf(false) }
var easterLocations by remember {
mutableStateOf(titles.map {
EasterLocation(
title = it,
isChecked = false
)
})
}
LaunchedEffect(Unit) {
val locations = readLocations(context)
if (locations.isNotEmpty()) {
easterLocations = locations
eggsFound = locations.count { it.isChecked }
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Scaffold { innerPadding ->
Column(
modifier = modifier
.background(Color(0xFF0F1241))
.background(
brush = Brush.verticalGradient(
listOf(
Color(0x0005061D),
Color(0xFF05061D)
)
)
)
.padding(innerPadding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
GradientText(
text = "Egg Hunt Checklist"
)
Spacer(Modifier.height(16.dp))
Text(
"Pick locations, where you’ve found eggs",
fontWeight = FontWeight.Black,
fontSize = 20.sp,
color = Color(0xFFFFFFFF)
)
Text(
"$eggsFound/10 eggs found",
fontWeight = FontWeight.Black,
fontSize = 20.sp,
color = Color(0xFFFFF583)
)
Spacer(Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(items = easterLocations) { index, location ->
LocationRowItem(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
easterLocation = location,
onCheckedChange = { isChecked ->
if (isChecked) {
eggsFound++
showEasterFactDialog = true
} else {
eggsFound--
showEggRollAwayDialog = true
}
easterLocations = easterLocations.toMutableList().apply {
set(index, location.copy(isChecked = isChecked))
}
scope.launch {
saveLocations(context, easterLocations)
}
}
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(100.dp))
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFFFFC441),
Color(0xFFCA7500),
)
)
)
.clickable {
easterLocations = easterLocations.toMutableList().map {
it.copy(isChecked = false)
}
scope.launch {
saveLocations(context, easterLocations)
}
}
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Reset",
fontWeight = FontWeight.Medium,
fontSize = 20.sp,
color = Color.White
)
}
}
}
if (showEasterFactDialog) {
EggHuntDialog(
title = "Easter fact!",
description = easterFacts.random(),
drawableRes = R.drawable.ic_easter_rabbit,
onDismiss = { showEasterFactDialog = false }
)
}
if (showEggRollAwayDialog) {
EggHuntDialog(
title = "Oops, the egg rolled away!",
drawableRes = R.drawable.ic_easter_egg,
onDismiss = { showEggRollAwayDialog = false }
)
}
}
}
@Serializable
data class EasterLocation(
val title: String,
val isChecked: Boolean,
)
@Composable
private fun LocationRowItem(
modifier: Modifier = Modifier,
easterLocation: EasterLocation,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.then(
if (easterLocation.isChecked) Modifier.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFFFFC441),
Color(0xFFCA7500),
)
)
)
else Modifier.background(Color(0xFF8D8EA1))
)
.clickable(onClick = {
onCheckedChange(!easterLocation.isChecked)
})
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
CheckBox(
isChecked = easterLocation.isChecked,
onCheckedChange = onCheckedChange
)
Text(
text = easterLocation.title,
fontWeight = FontWeight.Black,
fontSize = 16.sp,
color = Color(0xFFFFFFFF)
)
}
}
@Composable
private fun CheckBox(
modifier: Modifier = Modifier,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Box(
modifier = modifier
.size(24.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.clickable { onCheckedChange(!isChecked) },
contentAlignment = Alignment.Center
) {
if (isChecked) {
Image(
painter = painterResource(R.drawable.ic_checkbox),
contentDescription = "checkbox",
modifier = Modifier.size(14.dp)
)
}
}
}
@Composable
fun EggHuntDialog(
modifier: Modifier = Modifier,
title: String,
description: String? = null,
@DrawableRes drawableRes: Int,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF10122C))
.padding(24.dp)
) {
AnimatedGradientProgressBar(
modifier = Modifier.fillMaxWidth(),
onProgressFinished = onDismiss
)
Text(
text = title,
fontWeight = FontWeight.Black,
fontSize = 16.sp,
color = Color.White
)
Image(
painter = painterResource(drawableRes),
contentDescription = "easter rabbit",
modifier = Modifier.size(115.dp)
)
description?.let {
GradientText(
text = description,
fontSize = 16.sp,
textAlign = TextAlign.Center,
)
}
EasterButton(
modifier = Modifier.fillMaxWidth(),
text = "Dismiss",
onClick = onDismiss
)
}
}
}
@Composable
fun AnimatedGradientProgressBar(
modifier: Modifier = Modifier,
onProgressFinished: () -> Unit,
) {
val totalTime = 4000 // in ms
val progress = remember { Animatable(1f) }
var timeLeft by remember { mutableIntStateOf(4) }
LaunchedEffect(Unit) {
progress.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = totalTime, easing = LinearEasing)
)
// Final fallback to ensure time shows 0
timeLeft = 0
onProgressFinished()
}
LaunchedEffect(progress.value) {
timeLeft = (progress.value * 4).roundToInt()
}
val backgroundColor = Color(0xFF894621)
val gradient = Brush.horizontalGradient(
colors = listOf(Color(0xFFFFC441), Color(0xFFCA7500))
)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.height(6.dp)
.background(backgroundColor, RoundedCornerShape(8.dp))
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(1f - progress.value)
.background(gradient, RoundedCornerShape(8.dp))
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${timeLeft}s",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFF583)
)
}
}
@Composable
private fun EasterButton(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.border(2.dp, Color.White.copy(.4f), RoundedCornerShape(8.dp))
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFFFFC441),
Color(0xFFCA7500),
)
)
)
.clickable(onClick = onClick)
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontWeight = FontWeight.Black,
fontSize = 16.sp,
color = Color.White
)
}
}
@Preview
@Composable
private fun EggHuntDialogPreview() {
PlChallengesTheme {
EggHuntDialog(
title = "Easter fact!",
description = "The tradition of decorating eggs dates back to ancient civilizations,\n" +
"including Egyptians and Persians.",
drawableRes = R.drawable.ic_easter_rabbit,
onDismiss = {}
)
}
}
@Preview
@Composable
private fun EggHuntRollAwayDialogPreview() {
PlChallengesTheme {
EggHuntDialog(
title = "Oops, the egg rolled away!",
drawableRes = R.drawable.ic_easter_egg,
onDismiss = {}
)
}
}
@Preview
@Composable
private fun EggHuntPreview() {
PlChallengesTheme {
EggHunt(
modifier = Modifier.fillMaxSize()
)
}
}
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "easter_prefs")
suspend fun saveLocations(context: Context, locations: List<EasterLocation>) {
val json = Json.encodeToString(locations)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey(EASTER_KEY)] = json
}
}
suspend fun readLocations(context: Context): List<EasterLocation> {
val prefs = context.dataStore.data.first()
val json = prefs[stringPreferencesKey(EASTER_KEY)]
return if (!json.isNullOrEmpty()) {
Json.decodeFromString(json)
} else {
emptyList()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment