Files
LCDPet/app/src/main/java/today/notfeelingit/lcdpet/MainActivity.kt
T
2026-08-17 12:11:34 -04:00

5746 lines
208 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package today.notfeelingit.lcdpet
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationCompat
import android.app.PendingIntent
import android.net.Uri
import android.content.Intent
import android.content.Context
import android.app.NotificationManager
import android.app.NotificationChannel
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.border
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.TextStyle
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.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.compose.ui.platform.LocalLifecycleOwner
import today.notfeelingit.lcdpet.ui.theme.LCDPetTheme
private const val LCDPET_NOTIFICATION_CHANNEL_ID = "lcdpet_alerts"
private const val LCDPET_URGENT_NOTIFICATION_CHANNEL_ID = "lcdpet_urgent_alerts_v2"
private const val PET_SOUND_VOLUME = 0.85f
private fun postLCDPetNotification(
context: Context,
appIsVisible: Boolean,
notificationId: Int,
title: String,
message: String,
urgentNeed: Boolean = false,
allowInForeground: Boolean = false
) {
if (!allowInForeground && appIsVisible) {
if (urgentNeed) {
MediaPlayer.create(
context,
R.raw.need_urgent
)?.apply {
isLooping = false
setOnCompletionListener { completedPlayer ->
completedPlayer.release()
}
setOnErrorListener { failedPlayer, _, _ ->
failedPlayer.release()
true
}
start()
}
}
return
}
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
val launchIntent = Intent(
context,
MainActivity::class.java
).apply {
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
notificationId,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
val channelId =
if (urgentNeed) {
LCDPET_URGENT_NOTIFICATION_CHANNEL_ID
} else {
LCDPET_NOTIFICATION_CHANNEL_ID
}
val builder =
NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (
urgentNeed &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.O
) {
val urgentSoundUri =
Uri.parse(
"android.resource://${context.packageName}/" +
"${R.raw.need_urgent}"
)
builder.setSound(urgentSoundUri)
builder.setVibrate(longArrayOf(0L, 250L))
}
NotificationManagerCompat
.from(context)
.notify(notificationId, builder.build())
}
private data class CareJournalEntry(
val category: String,
val action: String,
val met: Boolean,
val responseMs: Long?,
val happenedAtMs: Long
)
private data class PetTemplate(
val id: String,
val label: String,
val topLine: String,
val facePrefix: String,
val faceSuffix: String,
val feetLine: String
)
private val lcdPetTemplates = listOf(
PetTemplate(
id = "classic",
label = "Classic",
topLine = " /---\\ \n",
facePrefix = "( ",
faceSuffix = " )\n",
feetLine = " /| |\\ "
),
PetTemplate(
id = "scruffy",
label = "Scruffy",
topLine = " /^^^\\ \n",
facePrefix = "( ",
faceSuffix = " )\n",
feetLine = " /| |\\ "
),
PetTemplate(
id = "bean",
label = "Round Bean",
topLine = " .---. \n",
facePrefix = "( ",
faceSuffix = " )\n",
feetLine = " /|\\ "
),
PetTemplate(
id = "pocketpup",
label = "Pocket Pup",
topLine = " /\\_/\\ \n",
facePrefix = "( ",
faceSuffix = " )\n",
feetLine = " /|\\ "
),
PetTemplate(
id = "batty",
label = "Batty",
topLine = "/\\---/\\\n",
facePrefix = "( ",
faceSuffix = " )\n",
feetLine = " /|\\ "
),
PetTemplate(
id = "goblin",
label = "Goblin",
topLine = " /\\_/\\ \n",
facePrefix = "( ",
faceSuffix = " )\n",
feetLine = " /| |\\ "
)
)
private fun lcdPetTemplateById(id: String): PetTemplate {
return lcdPetTemplates.firstOrNull { it.id == id } ?: lcdPetTemplates.first()
}
private fun renderPetTemplateText(template: PetTemplate, expression: String): String {
return template.topLine + template.facePrefix + expression + template.faceSuffix + template.feetLine
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
createLCDPetNotificationChannel()
setContent {
val appearance by applicationContext.lcdPetAppearanceFlow.collectAsState(
initial = "System"
)
val systemDarkTheme = isSystemInDarkTheme()
val useDarkTheme =
when (appearance) {
"Light" -> false
"Dark" -> true
else -> systemDarkTheme
}
LCDPetTheme(darkTheme = useDarkTheme) {
val systemBarColor = MaterialTheme.colorScheme.background
SideEffect {
window.statusBarColor = systemBarColor.toArgb()
window.navigationBarColor = systemBarColor.toArgb()
WindowCompat.getInsetsController(
window,
window.decorView
).apply {
isAppearanceLightStatusBars = !useDarkTheme
isAppearanceLightNavigationBars = !useDarkTheme
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background
) { innerPadding ->
Greeting(
name = "LCDPet",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
private fun createLCDPetNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
LCDPET_NOTIFICATION_CHANNEL_ID,
"LCDPet Alerts",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Pet care reminders and LCDPet status alerts."
}
val urgentSoundUri =
Uri.parse(
"android.resource://$packageName/${R.raw.need_urgent}"
)
val urgentAudioAttributes =
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val urgentChannel = NotificationChannel(
LCDPET_URGENT_NOTIFICATION_CHANNEL_ID,
"LCDPet Urgent Needs",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Urgent Hunger, Potty, Bath, and Energy alerts."
setSound(urgentSoundUri, urgentAudioAttributes)
enableVibration(true)
setVibrationPattern(longArrayOf(0L, 250L))
}
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
notificationManager.createNotificationChannel(urgentChannel)
}
}
}
@Composable
fun ActionLabel(
text: String,
enabled: Boolean,
lcdDark: Color,
lcdFaded: Color,
onClick: () -> Unit
) {
Text(
text = text,
fontWeight = FontWeight.Bold,
color = if (enabled) lcdDark else lcdFaded,
modifier = if (enabled) {
Modifier.clickable {
onClick()
}
} else {
Modifier
}
)
}
@Composable
fun LCDActionButton(
text: String,
enabled: Boolean,
lcdDark: Color,
lcdFaded: Color,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.border(2.dp, if (enabled) lcdDark else lcdFaded)
.clickable(enabled = enabled) {
onClick()
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontWeight = FontWeight.Bold,
color = if (enabled) lcdDark else lcdFaded,
textAlign = TextAlign.Center
)
}
}
@Composable
fun LCDMenuButton(
text: String,
enabled: Boolean,
lcdDark: Color,
lcdFaded: Color,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(if (enabled) lcdDark else lcdFaded)
.border(2.dp, if (enabled) lcdDark else lcdFaded)
.clickable(enabled = enabled) {
onClick()
}
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontWeight = FontWeight.Bold,
color =
if (enabled && lcdDark == Color.White) {
Color.Black
} else if (enabled) {
Color.White
} else {
lcdDark
},
textAlign = TextAlign.Center
)
}
}
@Composable
fun LCDSubButton(
text: String,
enabled: Boolean,
lcdDark: Color,
lcdFaded: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
fillWidthFraction: Float = 0.86f
) {
Box(
modifier = modifier
.fillMaxWidth(fillWidthFraction)
.border(2.dp, if (enabled) lcdDark else lcdFaded)
.clickable(enabled = enabled) {
onClick()
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontWeight = FontWeight.Bold,
color = if (enabled) lcdDark else lcdFaded,
textAlign = TextAlign.Center
)
}
}
@Composable
fun LCDHoldSubButton(
text: String,
enabled: Boolean,
lcdDark: Color,
lcdFaded: Color,
onPress: () -> Unit,
onRelease: (releasedNormally: Boolean) -> Unit,
modifier: Modifier = Modifier,
fillWidthFraction: Float = 0.86f
) {
Box(
modifier = modifier
.fillMaxWidth(fillWidthFraction)
.border(2.dp, if (enabled) lcdDark else lcdFaded)
.pointerInput(enabled) {
detectTapGestures(
onPress = {
if (enabled) {
onPress()
var releasedNormally = false
try {
releasedNormally = tryAwaitRelease()
} finally {
onRelease(releasedNormally)
}
}
}
)
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontWeight = FontWeight.Bold,
color = if (enabled) lcdDark else lcdFaded,
textAlign = TextAlign.Center
)
}
}
@Composable
fun LCDSubMenuFrame(
lcdDark: Color,
content: @Composable RowScope.() -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.border(2.dp, lcdDark)
.padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
val lcdBackground = Color(0xFFC4C7B0)
val lcdFrameDark = Color(0xFF343434)
val sickGreen = Color(0xFF2F7D32)
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val saveScope = rememberCoroutineScope()
val appearance by context.lcdPetAppearanceFlow.collectAsState(
initial = "System"
)
val petHomeColor by context.lcdPetHomeColorFlow.collectAsState(
initial = "LCD"
)
val petHomeBackground = when (petHomeColor) {
"R" -> Color(0xFFD98C8C)
"G" -> Color(0xFF8FBF8F)
"B" -> Color(0xFF8FAFD6)
"Y" -> Color(0xFFD8C66A)
"P" -> Color(0xFF9B7BC1)
"K" -> Color.Black
else -> lcdBackground
}
val lcdDark =
if (petHomeColor == "K") Color.White else lcdFrameDark
val lcdFaded = lcdDark.copy(alpha = 0.30f)
val petFrameForeground = lcdFrameDark
val petFrameFaded = petFrameForeground.copy(alpha = 0.30f)
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = {}
)
var microphonePermissionGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
)
}
val microphonePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
microphonePermissionGranted = granted
}
)
LaunchedEffect(Unit) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
val creditsText = remember(context) {
runCatching {
context.assets.open("audio_credits.txt").bufferedReader().use { it.readText() }
}.getOrElse {
"Credits unavailable."
}
}
val howToText = remember(context) {
runCatching {
context.assets.open("how_to.txt").bufferedReader().use { it.readText() }
}.getOrElse {
"HOW TO content could not be loaded."
}
}
val newPetWeatherOptions = listOf("Sunny", "Cloudy", "Rain", "Snow")
var location by remember { mutableStateOf("Inside") }
var weather by remember { mutableStateOf(newPetWeatherOptions.random()) }
var status by remember { mutableStateOf("Nervous") }
var hunger by remember { mutableIntStateOf(5) }
var health by remember { mutableIntStateOf(10) }
var happiness by remember { mutableIntStateOf(5) }
var energy by remember { mutableIntStateOf(5) }
var bond by remember { mutableIntStateOf(0) }
var dirtiness by remember { mutableIntStateOf(0) }
var potty by remember { mutableIntStateOf(0) }
var boredom by remember { mutableIntStateOf(0) }
var overfeedCount by remember { mutableIntStateOf(0) }
var starvingCount by remember { mutableIntStateOf(0) }
var overfeedNeedWindowOpen by remember { mutableStateOf(false) }
var hungerNeedWindowOpen by remember { mutableStateOf(false) }
var recoveryCount by remember { mutableIntStateOf(0) }
var sickCount by remember { mutableIntStateOf(0) }
var goodFeedCount by remember { mutableIntStateOf(0) }
var feedPottyCount by remember { mutableIntStateOf(0) }
var playEnergyCount by remember { mutableIntStateOf(0) }
var sameActionStreak by remember { mutableIntStateOf(0) }
var boredomTriggerAt by remember { mutableIntStateOf((3..13).random()) }
var boredomPenaltyCount by remember { mutableIntStateOf(0) }
var ultraTrollTriggered by remember { mutableStateOf(false) }
var lastActionName by remember { mutableStateOf("") }
var weakCause by remember { mutableStateOf("") }
var activityMood by remember { mutableStateOf("") }
var frameProp by remember { mutableStateOf("") }
var framePropAnimation by remember { mutableStateOf("") }
var framePropAnimationId by remember { mutableIntStateOf(0) }
var framePropStyle by remember { mutableIntStateOf(0) }
var pottyStain by remember { mutableStateOf("") }
var isSleeping by remember { mutableStateOf(false) }
var sleepMode by remember { mutableStateOf("") }
var roughWakeAlert by remember { mutableStateOf(false) }
var roughWakeDarkFlash by remember { mutableStateOf(false) }
var roughWakeNervousHold by remember { mutableStateOf(false) }
var restoredAutoSleep by remember { mutableStateOf(false) }
var restoredManualNap by remember { mutableStateOf(false) }
var autoSleepWakeAllowed by remember { mutableStateOf(false) }
var autoSleepStartedAtMs by remember { mutableStateOf(0L) }
var outsideEnergyNextTickAtMs by remember { mutableStateOf(0L) }
var isTradingInPet by remember { mutableStateOf(false) }
var isFindingNewPet by remember { mutableStateOf(false) }
var tradeInProgress by remember { mutableIntStateOf(0) }
var tradeInStartedAtMs by remember { mutableStateOf(0L) }
var tradeInRemainingMs by remember { mutableStateOf(0L) }
var tradeInConfirm by remember { mutableStateOf(false) }
var isBathing by remember { mutableStateOf(false) }
var isFeeding by remember { mutableStateOf(false) }
var feedingStartHunger by remember { mutableIntStateOf(0) }
var isTalking by remember { mutableStateOf(false) }
var microphoneRecorder by remember { mutableStateOf<AudioRecord?>(null) }
var microphoneLevel by remember { mutableIntStateOf(0) }
var talkHeardSound by remember { mutableStateOf(false) }
var bathBondAtStart by remember { mutableIntStateOf(0) }
var bathWantMatched by remember { mutableStateOf(false) }
var careMenuOpen by remember { mutableStateOf(false) }
var socializeMenuOpen by remember { mutableStateOf(false) }
var statsMenuOpen by remember { mutableStateOf(false) }
var careJournalOpen by remember { mutableStateOf(false) }
var infoPanelOpen by remember { mutableStateOf(false) }
var infoPanelType by remember { mutableStateOf("Credits") }
var infoPanelButtonCorner by remember { mutableStateOf("BottomRight") }
var careJournalTab by remember { mutableStateOf("All") }
var testWantMenuOpen by remember { mutableStateOf(false) }
var testBondMenuOpen by remember { mutableStateOf(false) }
var downtimeMode by remember { mutableStateOf(DowntimeMode.None) }
var selectedTvChannel by remember { mutableStateOf(TvChannel.Weather) }
var currentWant by remember { mutableStateOf("") }
var currentWantStartedAtMs by remember { mutableStateOf(0L) }
var nextWantAtMs by remember { mutableStateOf(0L) }
var suppressBackgroundWantOnNextStop by remember {
mutableStateOf(false)
}
var hungerNeedNotificationOpen by remember { mutableStateOf(false) }
var pottyNeedNotificationOpen by remember { mutableStateOf(false) }
var dirtinessNeedNotificationOpen by remember { mutableStateOf(false) }
var energyNeedNotificationOpen by remember { mutableStateOf(false) }
var careJournalEntries by remember { mutableStateOf<List<CareJournalEntry>>(emptyList()) }
var pottyFaceOverride by remember { mutableStateOf("") }
var pottyFaceBag by remember { mutableStateOf(emptyList<String>()) }
var petName by remember { mutableStateOf("") }
var pendingPetName by remember { mutableStateOf("") }
var petTemplateId by remember { mutableStateOf("classic") }
var pendingPetTemplateId by remember { mutableStateOf("classic") }
var happyOutsideTrip by remember { mutableStateOf(false) }
var petAgeDays by remember { mutableIntStateOf(0) }
var nextAgeAtMs by remember { mutableStateOf(0L) }
var careScore by remember { mutableIntStateOf(0) }
var neglectScore by remember { mutableIntStateOf(0) }
val testingMode = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
val secondMs = 1_000L
val minuteMs = 60L * secondMs
val hourMs = 60L * minuteMs
val bondCooldownMs = if (testingMode) 60_000L else 6L * hourMs
val sickTickMs = if (testingMode) 12_000L else 30L * minuteMs
val weakRecoveryCheckMs = if (testingMode) 25_000L else 2L * hourMs
val recoverWeakMs = if (testingMode) 25_000L else 2L * hourMs
val recoveringTickMs = if (testingMode) 5_000L else 10L * minuteMs
val hungerTickMs = if (testingMode) 20_000L else 2L * hourMs
val rainHappinessTickMs = if (testingMode) 20_000L else 1L * hourMs
val pottyTickMs = if (testingMode) 30_000L else 3L * hourMs
val dirtyHealthTickMs = if (testingMode) 60_000L else 12L * hourMs
val activityMoodMs = if (testingMode) 2_000L else 2_000L
val feedStepMs = if (testingMode) 450L else 10_000L
val propAnimationTickMs = if (testingMode) 180L else 180L
val petDayMs = if (testingMode) 60_000L else 24L * hourMs
val weatherShiftOptionsMs = if (testingMode) {
listOf(45_000L, 60_000L, 90_000L)
} else {
listOf(4L * hourMs, 6L * hourMs, 9L * hourMs, 12L * hourMs)
}
val wantCooldownOptionsMs = if (testingMode) {
listOf(45_000L, 60_000L, 90_000L)
} else {
listOf(8L * hourMs, 12L * hourMs, 18L * hourMs, 24L * hourMs)
}
val wantDurationMs = if (testingMode) 20_000L else 10L * minuteMs
val lowBondBathMs = if (testingMode) 2_000L else 2L * minuteMs
val mediumBondBathMs = if (testingMode) 4_000L else 4L * minuteMs
val trustedBathMs = if (testingMode) 6_000L else 6L * minuteMs
val sleepOptionsMs = if (testingMode) {
listOf(8_000L, 11_000L, 14_000L, 18_000L)
} else {
listOf(2L * hourMs, 3L * hourMs, 4L * hourMs, 5L * hourMs)
}
val autoSleepDelayMs = if (testingMode) 15_000L else 30L * minuteMs
val roughSleepEnergyTickMs = if (testingMode) 30_000L else 60L * 60L * 1000L
val offlineOutsideEnergyDrainTickMs = roughSleepEnergyTickMs
val roughWakeAlertMs = if (testingMode) 4_000L else 30_000L
val tradeInWaitMs = if (testingMode) 5_000L else 2L * hourMs
val findingNewPetMs = if (testingMode) 2_000L else 5_000L
val tradeInProgressBlocks = 10
var bondReady by remember { mutableStateOf(true) }
var nextWeatherAtMs by remember { mutableStateOf(0L) }
var bondReadyAtMs by remember { mutableStateOf(0L) }
var careCycleNextTickAtMs by remember { mutableStateOf(0L) }
var rainHappinessNextTickAtMs by remember { mutableStateOf(0L) }
var pottyNextTickAtMs by remember { mutableStateOf(0L) }
var dirtyHealthNextTickAtMs by remember { mutableStateOf(0L) }
var saveLoaded by remember { mutableStateOf(false) }
fun encodeCareJournalEntries(entries: List<CareJournalEntry>): String {
return entries.take(50).joinToString("\n") { entry ->
listOf(
entry.category,
entry.action,
entry.met.toString(),
entry.responseMs?.toString().orEmpty(),
entry.happenedAtMs.toString()
).joinToString("|")
}
}
fun sendLCDPetNotification(
notificationId: Int,
title: String,
message: String,
urgentNeed: Boolean = false,
allowInForeground: Boolean = false
) {
postLCDPetNotification(
context = context,
appIsVisible =
lifecycleOwner.lifecycle.currentState.isAtLeast(
Lifecycle.State.STARTED
),
notificationId = notificationId,
title = title,
message = message,
urgentNeed = urgentNeed,
allowInForeground = allowInForeground
)
}
fun wantNotificationText(wantName: String): String {
val notificationPetName = petName.ifBlank { "Your pet" }
return when (wantName) {
"Snack" -> "$notificationPetName wants a snack."
"Play" -> "$notificationPetName wants to play."
"Pet" -> "$notificationPetName wants pets."
"Talk" -> "$notificationPetName is looking for you."
"Outside" -> "$notificationPetName wants to go outside."
"Bath" -> "$notificationPetName wants a bath."
else -> "$notificationPetName wants your attention."
}
}
fun sendWantNotification(wantName: String) {
sendLCDPetNotification(
notificationId = 2001,
title = "LCDPet",
message = wantNotificationText(wantName)
)
}
fun sendNeedNotification(notificationId: Int, message: String) {
sendLCDPetNotification(
notificationId = notificationId,
title = "LCDPet",
message = message,
urgentNeed = true
)
}
fun sendTestNotification() {
sendLCDPetNotification(
notificationId = 1001,
title = "LCDPet",
message = "Test alert: your pet says beep boop.",
allowInForeground = true
)
}
fun decodeCareJournalEntries(raw: String): List<CareJournalEntry> {
return raw.lines()
.mapNotNull { line ->
val parts = line.split("|")
if (parts.size != 5) {
return@mapNotNull null
}
val met = when (parts[2]) {
"true" -> true
"false" -> false
else -> return@mapNotNull null
}
val responseMs = if (parts[3].isBlank()) {
null
} else {
parts[3].toLongOrNull()
}
val happenedAtMs = parts[4].toLongOrNull() ?: return@mapNotNull null
CareJournalEntry(
category = parts[0],
action = parts[1],
met = met,
responseMs = responseMs,
happenedAtMs = happenedAtMs
)
}
.take(50)
}
LaunchedEffect(Unit) {
context.lcdPetRecordSaveFlow.collect { save ->
// Only hydrate state from storage on the first load.
// After that, saves come from this screen and should not re-trigger restored auto-sleep.
if (saveLoaded) {
return@collect
}
bond = save.bond
petAgeDays = save.petAgeDays
nextAgeAtMs = save.nextAgeAtMs
careScore = save.careScore
neglectScore = save.neglectScore
hunger = save.hunger
health = save.health
happiness = save.happiness
energy = save.energy
dirtiness = save.dirtiness
potty = save.potty
boredom = save.boredom
starvingCount = save.starvingCount.coerceAtLeast(0)
recoveryCount = save.recoveryCount.coerceAtLeast(0)
sickCount = save.sickCount.coerceAtLeast(0)
playEnergyCount = save.playEnergyCount.coerceAtLeast(0)
sameActionStreak = save.sameActionStreak.coerceAtLeast(0)
boredomTriggerAt =
save.boredomTriggerAt.takeIf { it > 0 }
?: (3..13).random()
boredomPenaltyCount =
save.boredomPenaltyCount.coerceAtLeast(0)
ultraTrollTriggered = save.ultraTrollTriggered
lastActionName = save.lastActionName
location = save.location
weather = save.weather
status = save.status
weakCause = save.weakCause
pottyStain = save.pottyStain
happyOutsideTrip = save.happyOutsideTrip
isTradingInPet = save.isTradingInPet
isFindingNewPet = save.isFindingNewPet
tradeInProgress = save.tradeInProgress.coerceIn(0, tradeInProgressBlocks)
tradeInStartedAtMs = save.tradeInStartedAtMs
currentWant = save.currentWant
currentWantStartedAtMs = save.currentWantStartedAtMs
nextWantAtMs = save.nextWantAtMs
petName = save.petName
pendingPetName = save.petName
petTemplateId = save.petTemplateId.ifBlank { "classic" }
pendingPetTemplateId = petTemplateId
isSleeping = save.isSleeping
sleepMode = save.sleepMode
restoredManualNap =
save.isSleeping && save.sleepMode == "Nap"
roughWakeAlert = save.roughWakeAlert
autoSleepStartedAtMs = save.autoSleepStartedAtMs
outsideEnergyNextTickAtMs = save.outsideEnergyNextTickAtMs
nextWeatherAtMs = save.nextWeatherAtMs.coerceAtLeast(0L)
val restoredBondReadyAtMs =
save.bondReadyAtMs.coerceAtLeast(0L)
if (restoredBondReadyAtMs > System.currentTimeMillis()) {
bondReady = false
bondReadyAtMs = restoredBondReadyAtMs
} else {
bondReady = true
bondReadyAtMs = 0L
}
careCycleNextTickAtMs =
save.careCycleNextTickAtMs.coerceAtLeast(0L)
rainHappinessNextTickAtMs =
save.rainHappinessNextTickAtMs.coerceAtLeast(0L)
pottyNextTickAtMs =
save.pottyNextTickAtMs.coerceAtLeast(0L)
dirtyHealthNextTickAtMs =
save.dirtyHealthNextTickAtMs.coerceAtLeast(0L)
careJournalEntries = decodeCareJournalEntries(save.careJournalRaw)
autoSleepWakeAllowed = false
val nowMs = System.currentTimeMillis()
val firstOutsideEnergyTickAtMs =
if (save.outsideEnergyNextTickAtMs > 0L) {
save.outsideEnergyNextTickAtMs
} else {
save.lastSavedAtMs + offlineOutsideEnergyDrainTickMs
}
if (
!save.isSleeping &&
save.location == "Outside" &&
!save.isTradingInPet &&
!save.isFindingNewPet
) {
when {
save.energy <= 0 -> {
energy = 0
isSleeping = true
sleepMode = "Auto"
autoSleepStartedAtMs = save.lastSavedAtMs
outsideEnergyNextTickAtMs = 0L
restoredAutoSleep = true
}
nowMs >= firstOutsideEnergyTickAtMs -> {
val dueTicks =
1 + ((nowMs - firstOutsideEnergyTickAtMs) /
offlineOutsideEnergyDrainTickMs).toInt()
if (dueTicks >= save.energy) {
energy = 0
isSleeping = true
sleepMode = "Auto"
autoSleepStartedAtMs =
firstOutsideEnergyTickAtMs +
((save.energy - 1L) *
offlineOutsideEnergyDrainTickMs)
outsideEnergyNextTickAtMs = 0L
restoredAutoSleep = true
} else {
energy = (save.energy - dueTicks).coerceAtLeast(0)
outsideEnergyNextTickAtMs = 0L
restoredAutoSleep = false
}
}
else -> {
outsideEnergyNextTickAtMs = 0L
restoredAutoSleep = false
}
}
} else {
outsideEnergyNextTickAtMs = 0L
restoredAutoSleep =
save.isSleeping && save.sleepMode == "Auto"
}
val hasActiveSavedPet =
save.petName.isNotBlank() &&
save.petTemplateId.isNotBlank() &&
!save.isTradingInPet &&
!save.isFindingNewPet
if (hasActiveSavedPet) {
val savedNextAgeAtMs = save.nextAgeAtMs
when {
savedNextAgeAtMs <= 0L -> {
nextAgeAtMs = nowMs + petDayMs
}
nowMs >= savedNextAgeAtMs -> {
val dueAgeDays =
1L + ((nowMs - savedNextAgeAtMs) / petDayMs)
petAgeDays =
(save.petAgeDays.toLong() + dueAgeDays)
.coerceAtMost(Int.MAX_VALUE.toLong())
.toInt()
nextAgeAtMs =
savedNextAgeAtMs + (dueAgeDays * petDayMs)
}
else -> {
nextAgeAtMs = savedNextAgeAtMs
}
}
} else {
nextAgeAtMs = 0L
}
if (nextAgeAtMs != save.nextAgeAtMs) {
saveScope.launch {
context.setLCDPetNextAgeAtMs(nextAgeAtMs)
}
}
tradeInConfirm = false
// Do not repost an already-active Need warning when the activity
// is recreated after its notification is tapped or cold-opened
// with an overdue saved state.
hungerNeedNotificationOpen =
hunger >= 10 && starvingCount >= 2
pottyNeedNotificationOpen =
save.location == "Inside" && save.potty >= 9
dirtinessNeedNotificationOpen =
save.dirtiness >= 9 &&
save.status !in listOf(
"Sick",
"Weak",
"Recovering",
"RecoverWeak"
)
energyNeedNotificationOpen =
energy <= 1 && !isSleeping
saveLoaded = true
}
}
LaunchedEffect(
saveLoaded,
petName,
petTemplateId,
isTradingInPet,
isFindingNewPet,
nextAgeAtMs
) {
val hasActivePet =
saveLoaded &&
petName.isNotBlank() &&
petTemplateId.isNotBlank() &&
!isTradingInPet &&
!isFindingNewPet
if (!hasActivePet) {
return@LaunchedEffect
}
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (nextAgeAtMs > 0L) {
nextAgeAtMs
} else {
nowMs + petDayMs
}
if (nextAgeAtMs != dueAtMs) {
nextAgeAtMs = dueAtMs
context.setLCDPetNextAgeAtMs(dueAtMs)
return@LaunchedEffect
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
val tickNowMs = System.currentTimeMillis()
val dueAgeDays =
1L + ((tickNowMs - dueAtMs).coerceAtLeast(0L) / petDayMs)
petAgeDays =
(petAgeDays.toLong() + dueAgeDays)
.coerceAtMost(Int.MAX_VALUE.toLong())
.toInt()
nextAgeAtMs = dueAtMs + (dueAgeDays * petDayMs)
context.setLCDPetNextAgeAtMs(nextAgeAtMs)
}
fun lcdMeterBar(value: Int) = buildAnnotatedString {
val activeBlocks = value.coerceIn(0, 10)
withStyle(SpanStyle(color = lcdDark)) {
append("▓".repeat(activeBlocks))
}
withStyle(SpanStyle(color = lcdFaded)) {
append("▓".repeat(10 - activeBlocks))
}
}
val hungerBar = lcdMeterBar(hunger)
val healthBar = "\uD83D\uDDA4".repeat(health) + "\uD83E\uDE76".repeat(10 - health)
val happinessBar = lcdMeterBar(happiness)
val energyBar = lcdMeterBar(energy)
val bondBlocks = bond / 10
val bondBar = lcdMeterBar(bondBlocks)
val bondPercent = bond
val dirtinessBar = lcdMeterBar(dirtiness)
val pottyBar = lcdMeterBar(potty)
val boredomBar = lcdMeterBar(boredom)
fun statAgeText(days: Int): String {
fun amount(value: Int, single: String, plural: String): String {
return "$value ${if (value == 1) single else plural}"
}
return when {
days < 7 -> amount(days, "day", "days")
days < 30 -> amount((days / 7).coerceAtLeast(1), "week", "weeks")
days < 365 -> amount((days / 30).coerceAtLeast(1), "month", "months")
else -> amount((days / 365).coerceAtLeast(1), "year", "years")
}
}
val petAgeText = statAgeText(petAgeDays)
val displayPetName = petName.ifBlank { "Pet" }
val needsPetName = saveLoaded && (petName.isBlank() || petTemplateId.isBlank()) && !isTradingInPet && !isFindingNewPet
val trimmedPendingPetName = pendingPetName.trim()
val normalizedPendingPetName = trimmedPendingPetName.lowercase()
val isNameValidationActive = !testingMode
val reservedPetNames = setOf("patch", "petch")
val isReservedPetName = isNameValidationActive && normalizedPendingPetName in reservedPetNames
val hasValidPetNameLetters = trimmedPendingPetName.isNotEmpty() && trimmedPendingPetName.all { it.isLetter() }
val canKeepPetName = hasValidPetNameLetters && !isReservedPetName
val pendingPetTemplate = lcdPetTemplateById(pendingPetTemplateId)
val selectedPetTemplate = lcdPetTemplateById(petTemplateId)
val bondRelationship = when {
bond >= 100 -> "BFF"
bond >= 80 -> "Best Friend"
bond >= 50 -> "Pal"
bond >= 25 -> "Settled"
else -> "New Pet"
}
val tradeInProgressBar = "▓".repeat(tradeInProgress) + "▒".repeat(tradeInProgressBlocks - tradeInProgress)
fun tradeCountdownText(remainingMs: Long): String {
val totalSeconds = ((remainingMs + 999L) / 1_000L).coerceAtLeast(0L)
val hours = totalSeconds / 3_600L
val minutes = (totalSeconds % 3_600L) / 60L
val seconds = totalSeconds % 60L
return when {
hours > 0L -> "${hours}h ${minutes}m"
minutes > 0L -> "${minutes}m ${seconds}s"
else -> "${seconds}s"
}
}
fun addCareJournalEntry(
category: String,
action: String,
met: Boolean,
responseMs: Long? = null
) {
val nowMs = System.currentTimeMillis()
val entry = CareJournalEntry(
category = category,
action = action,
met = met,
responseMs = responseMs,
happenedAtMs = nowMs
)
careJournalEntries = (listOf(entry) + careJournalEntries).take(50)
}
fun logNeedJournal(action: String, met: Boolean) {
addCareJournalEntry(
category = "Need",
action = action,
met = met,
responseMs = null
)
}
fun logWantJournal(action: String, met: Boolean) {
val nowMs = System.currentTimeMillis()
val responseMs = if (met && currentWantStartedAtMs > 0L) {
(nowMs - currentWantStartedAtMs).coerceAtLeast(0L)
} else {
null
}
addCareJournalEntry(
category = "Want",
action = action,
met = met,
responseMs = responseMs
)
}
fun journalResponseText(responseMs: Long?): String {
if (responseMs == null) {
return "N/A"
}
return when {
responseMs < minuteMs -> "${(responseMs / secondMs).coerceAtLeast(1L)}s"
responseMs < hourMs -> "${(responseMs / minuteMs).coerceAtLeast(1L)}m"
else -> "${(responseMs / hourMs).coerceAtLeast(1L)}h"
}
}
fun journalDateText(happenedAtMs: Long): String {
val ageMs = (System.currentTimeMillis() - happenedAtMs).coerceAtLeast(0L)
return when {
ageMs < secondMs -> "now"
ageMs < minuteMs -> "${(ageMs / secondMs).coerceAtLeast(1L)}s ago"
ageMs < hourMs -> "${(ageMs / minuteMs).coerceAtLeast(1L)}m ago"
ageMs < 24L * hourMs -> "${(ageMs / hourMs).coerceAtLeast(1L)}h ago"
ageMs < 7L * 24L * hourMs -> "${(ageMs / (24L * hourMs)).coerceAtLeast(1L)}d ago"
else -> "${(ageMs / (7L * 24L * hourMs)).coerceAtLeast(1L)}w ago"
}
}
val wantText = when (currentWant) {
"Snack" -> "$displayPetName wants a Snack!"
"Play" -> "$displayPetName wants to Play!"
"Pet" -> "$displayPetName wants Pets!"
"Talk" -> "$displayPetName is looking for you!"
"Outside" -> "$displayPetName wants to go Outside!"
"Bath" -> "$displayPetName wants a Bath!"
else -> ""
}
val recoveryNeeded = (3 + sickCount - if (happiness >= 8) 1 else 0).coerceAtLeast(1)
val isOutsideGoodWeather =
location == "Outside" && weather in listOf("Sunny", "Cloudy")
val isOutsideRain = location == "Outside" && weather == "Rain"
val isOutsideSnow = location == "Outside" && weather == "Snow"
val displayStatus = when {
isFindingNewPet -> "Trading"
status in listOf("Sick", "Weak", "Recovering", "RecoverWeak") -> status
roughWakeAlert -> "RoughWake"
roughWakeNervousHold -> "Nervous"
isSleeping -> "Asleep"
status == "Nervous" -> "Nervous"
// If the pet asked to go outside, the whole outing is exciting.
location == "Outside" && happyOutsideTrip -> "Happy"
// Rain is scary until full trust.
isOutsideRain && bond < 100 -> "Nervous"
// Snow trips stay calm and brief because the player does not want to linger.
isOutsideSnow -> "Calm"
// Temporary moods, like briefly enjoying good weather, show before fallback mood.
activityMood.isNotEmpty() -> activityMood
// Outside trust tiers.
isOutsideGoodWeather && bond < 40 -> "Nervous"
isOutsideGoodWeather && bond < 90 -> "Calm"
isOutsideGoodWeather -> "Happy"
// Inside trust tiers: low bond should not look fully happy yet.
happiness <= 3 -> "Sad"
bond < 30 -> "Nervous"
bond < 80 -> "Calm"
else -> "Happy"
}
val petExpression = pottyFaceOverride.ifBlank {
when (displayStatus) {
"Calm" -> "o_o"
"Excited" -> "^o^"
"Asleep" -> "-.-"
"Sad" -> ";_;"
"Nervous" -> "o_O"
"Sick" -> ">_<"
"Weak" -> "-_-"
"Recovering" -> "o_<"
"RecoverWeak" -> "o_-"
"Trading" -> "..."
else -> "^_^"
}
}
val petArt = buildAnnotatedString {
append(selectedPetTemplate.topLine)
append(selectedPetTemplate.facePrefix)
withStyle(SpanStyle(fontWeight = if (pottyFaceOverride.isNotEmpty()) FontWeight.Bold else FontWeight.Normal)) {
append(petExpression)
}
append(selectedPetTemplate.faceSuffix)
append(selectedPetTemplate.feetLine)
}
val petColor =
if (status == "Sick") sickGreen else petFrameForeground
val alertText = when {
// Locked event moments.
roughWakeAlert -> "Rough wake!"
roughWakeNervousHold -> "Nervous"
isFindingNewPet -> "Finding new pet..."
isTradingInPet -> "Trading pet..."
// Consequence alerts: show the cause when sickness came from a missed need.
weakCause == "Starving" &&
hunger >= 9 &&
status in listOf("Sick", "Weak") ->
"$displayPetName is hungry and sick!"
weakCause == "Overfed" &&
hunger <= 1 &&
status in listOf("Sick", "Weak") ->
"$displayPetName is stuffed and sick!"
weakCause == "Dirty" &&
dirtiness >= 10 &&
status in listOf("Sick", "Weak") ->
"$displayPetName needs a bath and is sick!"
// Need warnings.
energy <= 0 && !isSleeping -> "$displayPetName needs a nap!"
energy == 1 && !isSleeping -> "$displayPetName is exhausted!"
hunger >= 9 -> "$displayPetName is hungry!"
hunger <= 1 -> "$displayPetName is stuffed!"
dirtiness >= 9 -> "$displayPetName needs a bath!"
location == "Inside" && potty >= 9 -> "$displayPetName has to potty!"
pottyStain.isNotEmpty() -> "$displayPetName had an accident!"
// Health/status consequences.
status == "Weak" -> "$displayPetName is weak!"
status == "Sick" -> "$displayPetName is sick!"
status in listOf("Recovering", "RecoverWeak") -> "$displayPetName is recovering!"
// Immediate player-action warnings.
location == "Outside" && weather == "Rain" && bond < 100 && !happyOutsideTrip -> "Bring inside!"
isOutsideSnow -> if (potty >= 3) {
"\"Maybe a quick potty, then back in?\""
} else {
"\"It's cold, let's go back in\""
}
// Wants beat low-priority mood chatter.
wantText.isNotEmpty() -> wantText
// Low-priority mood alerts.
boredom >= 7 -> "$displayPetName is bored!"
else -> ""
}
val framePropDisplay = when {
frameProp.isNotEmpty() -> frameProp
pottyStain.isNotEmpty() -> pottyStain
else -> " "
}
val framePropText = buildAnnotatedString {
when {
framePropAnimation == "Nap" && framePropDisplay == "ZzZ" -> {
framePropDisplay.forEachIndexed { index, character ->
val isDark = if (framePropStyle % 2 == 0) {
index % 2 == 0
} else {
index % 2 != 0
}
withStyle(
SpanStyle(
color =
if (isDark) petFrameForeground else petFrameFaded,
fontWeight = FontWeight.Bold
)
) {
append(character)
}
}
}
framePropAnimation == "Bath" && framePropDisplay == "≈≈≈" -> {
framePropDisplay.forEachIndexed { index, character ->
val isActive = index == framePropStyle % 3
withStyle(
SpanStyle(
color =
if (isActive) petFrameForeground else petFrameFaded,
fontWeight = if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
}
)
) {
append(character)
}
}
}
else -> {
append(framePropDisplay)
}
}
}
fun nextWeather(currentWeather: String): String {
return when (currentWeather) {
"Sunny" -> "Cloudy"
"Cloudy" -> listOf("Sunny", "Rain", "Snow").random()
"Rain" -> "Cloudy"
"Snow" -> "Cloudy"
else -> "Sunny"
}
}
fun buildBond(multiplier: Int = 1, force: Boolean = false) {
if ((bondReady || force) && bond < 100) {
val bondGain = when {
bond <= 20 -> 2
bond <= 50 -> 3
bond <= 80 -> 5
else -> 7
}
bond = (bond + (bondGain * multiplier)).coerceAtMost(100)
if (!force) {
bondReady = false
bondReadyAtMs =
System.currentTimeMillis() + bondCooldownMs
}
}
}
fun toggleLocation() {
if (isTradingInPet || isFindingNewPet || isSleeping || isBathing || isTalking || framePropAnimation.isNotEmpty() || frameProp.isNotEmpty()) {
return
}
if (location == "Inside" && status in listOf("Weak", "RecoverWeak")) {
return
}
careMenuOpen = false
socializeMenuOpen = false
statsMenuOpen = false
if (location == "Inside") {
val outsideWantMatched = currentWant == "Outside"
if (outsideWantMatched) {
currentWant = ""
currentWantStartedAtMs = 0L
happyOutsideTrip = true
}
location = "Outside"
val newWeather = weather
if (outsideWantMatched) {
happiness = 10
activityMood = "Happy"
buildBond(force = true)
} else {
dirtiness = if (newWeather == "Rain") 10 else (dirtiness + 1).coerceAtMost(10)
happiness = if (newWeather == "Rain") 8 else 10
}
if (status !in listOf("Sick", "Recovering", "Weak", "RecoverWeak")) {
status = if (newWeather == "Rain" && !outsideWantMatched) "Nervous" else "Calm"
if (newWeather != "Rain" || outsideWantMatched) {
activityMood = "Happy"
}
}
} else {
location = "Inside"
happyOutsideTrip = false
if (status !in listOf("Sick", "Recovering", "Weak", "RecoverWeak")) {
status = "Calm"
}
}
}
fun playPetSound(soundResource: Int) {
val player = MediaPlayer.create(context, soundResource) ?: return
player.apply {
isLooping = false
setVolume(PET_SOUND_VOLUME, PET_SOUND_VOLUME)
setOnCompletionListener { completedPlayer ->
completedPlayer.release()
}
setOnErrorListener { failedPlayer, _, _ ->
failedPlayer.release()
true
}
start()
}
}
fun damageHealth(): Int {
val newHealth = (health - 1).coerceAtLeast(0)
health = newHealth
if (newHealth == 0) {
careCycleNextTickAtMs = 0L
status = "Weak"
}
return newHealth
}
fun startFrameAnimation(animationName: String) {
framePropAnimation = animationName
framePropAnimationId += 1
}
fun completeWant(wantName: String): Boolean {
if (currentWant == wantName) {
logWantJournal(wantName, met = true)
currentWant = ""
currentWantStartedAtMs = 0L
return true
}
return false
}
fun choosePottyFace() {
val pottyFaces = listOf(
"¬.¬",
"=.=",
"•.•",
">.>",
"¬_¬",
"=_=",
"•_•",
">_>",
)
if (pottyFaceBag.isEmpty()) {
pottyFaceBag = pottyFaces.shuffled()
}
pottyFaceOverride = pottyFaceBag.first()
pottyFaceBag = pottyFaceBag.drop(1)
}
fun forceTestWant(wantName: String) {
location = "Inside"
weather = "Sunny"
happyOutsideTrip = false
if (status !in listOf("Sick", "Recovering", "Weak", "RecoverWeak")) {
status = "Calm"
}
currentWant = wantName
currentWantStartedAtMs = System.currentTimeMillis()
sendWantNotification(wantName)
careMenuOpen = false
socializeMenuOpen = false
statsMenuOpen = false
testWantMenuOpen = false
testBondMenuOpen = false
}
fun applyBoredomPenalty() {
val boredomPenaltyEvery = when {
boredom < 4 -> 4
boredom < 7 -> 3
boredom < 9 -> 2
else -> 1
}
boredomPenaltyCount += 1
if (boredomPenaltyCount >= boredomPenaltyEvery) {
boredomPenaltyCount = 0
val statLoss = if (boredom >= 9) 2 else 1
happiness = (happiness - statLoss).coerceAtLeast(0)
energy = (energy - statLoss).coerceAtLeast(0)
}
}
fun recordAction(actionName: String) {
if (lastActionName == actionName) {
sameActionStreak += 1
if (sameActionStreak >= boredomTriggerAt) {
boredom = (boredom + 1).coerceAtMost(10)
applyBoredomPenalty()
boredomTriggerAt = sameActionStreak + (3..13).random()
}
if (sameActionStreak >= 25 && !ultraTrollTriggered) {
bond = (bond - 5).coerceAtLeast(0)
ultraTrollTriggered = true
}
} else {
lastActionName = actionName
sameActionStreak = 1
boredomTriggerAt = (3..13).random()
ultraTrollTriggered = false
boredom = (boredom - 1).coerceAtLeast(0)
boredomPenaltyCount = (boredomPenaltyCount - 1).coerceAtLeast(0)
}
}
fun feedPet() {
if (isFeeding) {
return
}
framePropAnimation = ""
framePropStyle = 0
frameProp = "[___]"
if (completeWant("Snack")) {
playPetSound(R.raw.pet_eating)
// A requested snack is a tiny treat, not a full meal.
happiness = (happiness + 1).coerceAtMost(10)
energy = (energy + 1).coerceAtMost(10)
starvingCount = 0
overfeedCount = 0
hungerNeedWindowOpen = false
overfeedNeedWindowOpen = false
careScore = (careScore + 1).coerceAtMost(999)
buildBond(force = true)
return
}
if (hunger <= 2) {
return
}
// Full meals are exclusive care time: no active or pending Wants.
currentWant = ""
currentWantStartedAtMs = 0L
nextWantAtMs = 0L
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_background_want"
)
androidx.core.app.NotificationManagerCompat
.from(context)
.cancel(2001)
saveScope.launch {
context.clearLCDPetWants()
}
feedingStartHunger = hunger
isFeeding = true
playPetSound(R.raw.pet_eating)
activityMood = "Eating"
framePropAnimation = "Feed"
framePropStyle = 0
frameProp = "[###]"
}
fun playPet() {
if (energy > 0) {
playPetSound(R.raw.pet_excited)
val wantMatched = completeWant("Play")
if (!wantMatched) {
recordAction("Play")
}
activityMood = "Excited"
startFrameAnimation("Ball")
careScore = (careScore + 1).coerceAtMost(999)
if (wantMatched) {
// Wanted play is an invited burst of fun.
// It should not drain hunger, energy, or build boredom.
happiness = (happiness + 2).coerceAtMost(10)
playEnergyCount = 0
buildBond(multiplier = 2, force = true)
} else {
happiness = (happiness + 1).coerceAtMost(10)
hunger = (hunger + 1).coerceAtMost(10)
playEnergyCount += 1
if (playEnergyCount >= 2) {
energy = (energy - 1).coerceAtLeast(0)
playEnergyCount = 0
}
}
}
}
fun petPet() {
val wantMatched = completeWant("Pet")
if (!wantMatched) {
recordAction("Pet")
}
activityMood = if (bond >= 100) "Happy" else "Calm"
startFrameAnimation("Hand")
careScore = (careScore + 1).coerceAtMost(999)
happiness = (happiness + 1).coerceAtMost(10)
if (wantMatched) {
// Wanted pets are requested attention, not repetitive pestering.
buildBond(multiplier = 2, force = true)
} else {
buildBond()
}
}
fun registerHeardTalk() {
if (!isTalking || talkHeardSound) {
return
}
// TALK succeeds when the microphone actually detects sound.
// Gesture release, sliding away, or lifecycle interruption only
// controls recorder cleanup and must not decide whether the pet heard it.
talkHeardSound = true
val wantMatched = completeWant("Talk")
if (!wantMatched) {
recordAction("Talk")
}
careScore = (careScore + 1).coerceAtMost(999)
happiness = (happiness + 1).coerceAtMost(10)
if (wantMatched) {
// Wanted talk is requested attention, not repetitive pestering.
buildBond(multiplier = 5, force = true)
} else {
buildBond()
}
}
@Suppress("MissingPermission")
fun beginTalk() {
if (
microphonePermissionGranted &&
!isTradingInPet &&
!isFindingNewPet &&
!isSleeping &&
!isBathing &&
framePropAnimation.isEmpty()
) {
isTalking = true
talkHeardSound = false
activityMood = if (bond >= 100) "Happy" else "Calm"
framePropAnimation = ""
framePropStyle = 0
frameProp = "¢==-"
microphoneLevel = 0
val sampleRate = 16_000
val minimumBufferSize = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
val bufferSize = minimumBufferSize.coerceAtLeast(2_048)
val recorder = runCatching {
AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize
)
}.getOrNull()
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder?.release()
isTalking = false
microphoneRecorder = null
microphoneLevel = 0
talkHeardSound = false
frameProp = ""
return
}
microphoneRecorder = recorder
recorder.startRecording()
saveScope.launch {
withContext(Dispatchers.IO) {
val audioBuffer = ShortArray(bufferSize)
while (recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
val samplesRead = recorder.read(
audioBuffer,
0,
audioBuffer.size
)
if (samplesRead > 0) {
var peak = 0
for (index in 0 until samplesRead) {
peak = maxOf(peak, abs(audioBuffer[index].toInt()))
}
val level = when {
peak >= 12_000 -> 3
peak >= 5_000 -> 2
peak >= 1_500 -> 1
else -> 0
}
withContext(Dispatchers.Main) {
if (isTalking && microphoneRecorder === recorder) {
microphoneLevel = level
frameProp = when (level) {
3 -> "¢==-)))"
2 -> "¢==-))"
1 -> "¢==-)"
else -> "¢==-"
}
if (level > 0) {
registerHeardTalk()
}
}
}
}
}
}
}
}
}
fun endTalk() {
if (isTalking || microphoneRecorder != null) {
isTalking = false
microphoneRecorder?.let { recorder ->
runCatching {
if (recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
recorder.stop()
}
}
recorder.release()
}
microphoneRecorder = null
microphoneLevel = 0
talkHeardSound = false
frameProp = ""
}
}
fun walkPet() {
if (energy > 0) {
playPetSound(R.raw.pet_excited)
activityMood = "Excited"
startFrameAnimation("Footprints")
careScore = (careScore + 1).coerceAtMost(999)
health = 10
energy = (energy - 1).coerceAtLeast(0)
hunger = (hunger + 1).coerceAtMost(10)
dirtiness = (dirtiness + 1).coerceAtMost(10)
}
}
fun napPet() {
roughWakeAlert = false
// For a manual Nap, this field stores the fixed wake deadline.
// Choosing it here prevents reopening the app from rolling a new duration.
autoSleepStartedAtMs =
System.currentTimeMillis() + sleepOptionsMs.random()
sleepMode = "Nap"
isSleeping = true
activityMood = "Asleep"
startFrameAnimation("Nap")
}
fun usePotty() {
if (location == "Outside") {
val pottyNeedMet = potty > 0
choosePottyFace()
startFrameAnimation("PottyClean")
careScore = (careScore + 1).coerceAtMost(999)
pottyNextTickAtMs = 0L
potty = 0
hunger = (hunger + 1).coerceAtMost(10)
if (pottyNeedMet) {
logNeedJournal("Potty", met = true)
}
}
}
fun bathePet() {
if (location == "Inside" && (dirtiness > 0 || currentWant == "Bath") && !isTradingInPet && !isFindingNewPet && !isSleeping && !isBathing) {
val bathNeedMet = dirtiness > 0 || pottyStain.isNotEmpty()
bathWantMatched = completeWant("Bath")
bathBondAtStart = bond
pottyStain = ""
isBathing = true
activityMood = when {
bond >= 100 -> "Happy"
bond >= 60 -> "Calm"
else -> "Nervous"
}
startFrameAnimation("Bath")
if (bathNeedMet) {
logNeedJournal("Bath", met = true)
}
}
}
fun resetPetAfterTradeIn() {
location = "Inside"
weather = newPetWeatherOptions.random()
nextWeatherAtMs = 0L
status = "Nervous"
hunger = 5
health = 10
happiness = 5
energy = 5
bond = 0
dirtiness = 0
potty = 0
boredom = 0
overfeedCount = 0
starvingCount = 0
overfeedNeedWindowOpen = false
hungerNeedWindowOpen = false
hungerNeedNotificationOpen = false
pottyNeedNotificationOpen = false
dirtinessNeedNotificationOpen = false
energyNeedNotificationOpen = false
recoveryCount = 0
sickCount = 0
goodFeedCount = 0
feedPottyCount = 0
playEnergyCount = 0
sameActionStreak = 0
boredomTriggerAt = (3..13).random()
boredomPenaltyCount = 0
ultraTrollTriggered = false
lastActionName = ""
weakCause = ""
activityMood = ""
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
pottyStain = ""
isSleeping = false
sleepMode = ""
autoSleepStartedAtMs = 0L
restoredAutoSleep = false
autoSleepWakeAllowed = false
outsideEnergyNextTickAtMs = 0L
careCycleNextTickAtMs = 0L
rainHappinessNextTickAtMs = 0L
pottyNextTickAtMs = 0L
dirtyHealthNextTickAtMs = 0L
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_outside_energy_warning"
)
saveScope.launch {
context.setLCDPetOutsideEnergyNextTickAtMs(0L)
}
roughWakeAlert = false
roughWakeDarkFlash = false
roughWakeNervousHold = false
isBathing = false
isTalking = false
bathBondAtStart = 0
bathWantMatched = false
careMenuOpen = false
socializeMenuOpen = false
statsMenuOpen = false
currentWant = ""
currentWantStartedAtMs = 0L
careJournalOpen = false
infoPanelOpen = false
careJournalTab = "All"
careJournalEntries = emptyList()
petName = ""
pendingPetName = ""
petTemplateId = "classic"
pendingPetTemplateId = "classic"
happyOutsideTrip = false
petAgeDays = 0
nextAgeAtMs = 0L
saveScope.launch {
context.setLCDPetNextAgeAtMs(0L)
}
careScore = 0
neglectScore = 0
bondReady = true
bondReadyAtMs = 0L
tradeInConfirm = false
isTradingInPet = false
isFindingNewPet = false
tradeInProgress = 0
tradeInStartedAtMs = 0L
}
fun beginTradeInPet() {
if (!isTradingInPet && !isFindingNewPet) {
careMenuOpen = false
socializeMenuOpen = false
currentWant = ""
currentWantStartedAtMs = 0L
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
pottyStain = ""
isSleeping = false
isBathing = false
isTalking = false
sleepMode = ""
autoSleepStartedAtMs = 0L
tradeInConfirm = false
tradeInProgress = 0
tradeInStartedAtMs = System.currentTimeMillis()
isTradingInPet = true
// Force-save trade start immediately so closing the app mid-trade resumes correctly.
saveScope.launch {
context.saveLCDPetStats(
bond = bond,
petAgeDays = petAgeDays,
careScore = careScore,
neglectScore = neglectScore,
hunger = hunger,
health = health,
happiness = happiness,
energy = energy,
dirtiness = dirtiness,
potty = potty,
boredom = boredom,
starvingCount = starvingCount,
recoveryCount = recoveryCount,
sickCount = sickCount,
playEnergyCount = playEnergyCount,
sameActionStreak = sameActionStreak,
boredomTriggerAt = boredomTriggerAt,
boredomPenaltyCount = boredomPenaltyCount,
ultraTrollTriggered = ultraTrollTriggered,
lastActionName = lastActionName,
location = location,
weather = weather,
status = status,
weakCause = weakCause,
pottyStain = pottyStain,
happyOutsideTrip = happyOutsideTrip,
isTradingInPet = true,
isFindingNewPet = false,
tradeInProgress = 0,
tradeInStartedAtMs = tradeInStartedAtMs,
currentWant = currentWant,
currentWantStartedAtMs = currentWantStartedAtMs,
nextWantAtMs = nextWantAtMs,
petName = petName,
petTemplateId = petTemplateId,
isSleeping = isSleeping,
sleepMode = sleepMode,
roughWakeAlert = roughWakeAlert,
autoSleepStartedAtMs = autoSleepStartedAtMs,
nextWeatherAtMs = nextWeatherAtMs,
bondReadyAtMs = bondReadyAtMs,
careCycleNextTickAtMs = careCycleNextTickAtMs,
rainHappinessNextTickAtMs = rainHappinessNextTickAtMs,
pottyNextTickAtMs = pottyNextTickAtMs,
dirtyHealthNextTickAtMs = dirtyHealthNextTickAtMs,
careJournalRaw = encodeCareJournalEntries(careJournalEntries)
)
}
}
}
fun cancelTradeInPet() {
if (isTradingInPet) {
isTradingInPet = false
tradeInConfirm = false
tradeInProgress = 0
tradeInStartedAtMs = 0L
// Force-save trade cancel immediately.
saveScope.launch {
context.saveLCDPetStats(
bond = bond,
petAgeDays = petAgeDays,
careScore = careScore,
neglectScore = neglectScore,
hunger = hunger,
health = health,
happiness = happiness,
energy = energy,
dirtiness = dirtiness,
potty = potty,
boredom = boredom,
starvingCount = starvingCount,
recoveryCount = recoveryCount,
sickCount = sickCount,
playEnergyCount = playEnergyCount,
sameActionStreak = sameActionStreak,
boredomTriggerAt = boredomTriggerAt,
boredomPenaltyCount = boredomPenaltyCount,
ultraTrollTriggered = ultraTrollTriggered,
lastActionName = lastActionName,
location = location,
weather = weather,
status = status,
weakCause = weakCause,
pottyStain = pottyStain,
happyOutsideTrip = happyOutsideTrip,
isTradingInPet = false,
isFindingNewPet = false,
tradeInProgress = 0,
tradeInStartedAtMs = 0L,
currentWant = currentWant,
currentWantStartedAtMs = currentWantStartedAtMs,
nextWantAtMs = nextWantAtMs,
petName = petName,
petTemplateId = petTemplateId,
isSleeping = isSleeping,
sleepMode = sleepMode,
roughWakeAlert = roughWakeAlert,
autoSleepStartedAtMs = autoSleepStartedAtMs,
nextWeatherAtMs = nextWeatherAtMs,
bondReadyAtMs = bondReadyAtMs,
careCycleNextTickAtMs = careCycleNextTickAtMs,
rainHappinessNextTickAtMs = rainHappinessNextTickAtMs,
pottyNextTickAtMs = pottyNextTickAtMs,
dirtyHealthNextTickAtMs = dirtyHealthNextTickAtMs,
careJournalRaw = encodeCareJournalEntries(careJournalEntries)
)
}
}
}
fun cleanTestPet() {
if (!testingMode || isTradingInPet || isFindingNewPet) {
return
}
location = "Inside"
weather = "Sunny"
nextWeatherAtMs = 0L
status = if (bond < 30) "Nervous" else "Calm"
hunger = 5
health = 10
happiness = 5
energy = 10
dirtiness = 0
potty = 0
boredom = 0
overfeedCount = 0
starvingCount = 0
overfeedNeedWindowOpen = false
hungerNeedWindowOpen = false
hungerNeedNotificationOpen = false
pottyNeedNotificationOpen = false
dirtinessNeedNotificationOpen = false
energyNeedNotificationOpen = false
recoveryCount = 0
sickCount = 0
goodFeedCount = 0
feedPottyCount = 0
playEnergyCount = 0
sameActionStreak = 0
boredomTriggerAt = (3..13).random()
boredomPenaltyCount = 0
ultraTrollTriggered = false
lastActionName = ""
weakCause = ""
activityMood = ""
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
pottyStain = ""
isSleeping = false
sleepMode = ""
autoSleepStartedAtMs = 0L
roughWakeAlert = false
roughWakeDarkFlash = false
roughWakeNervousHold = false
isBathing = false
isFeeding = false
feedingStartHunger = 0
isTalking = false
bathBondAtStart = 0
bathWantMatched = false
careMenuOpen = false
socializeMenuOpen = false
statsMenuOpen = false
currentWant = ""
currentWantStartedAtMs = 0L
nextWantAtMs = 0L
outsideEnergyNextTickAtMs = 0L
careCycleNextTickAtMs = 0L
rainHappinessNextTickAtMs = 0L
pottyNextTickAtMs = 0L
dirtyHealthNextTickAtMs = 0L
suppressBackgroundWantOnNextStop = false
happyOutsideTrip = false
bondReady = true
bondReadyAtMs = 0L
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_background_want"
)
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_outside_energy_warning"
)
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_background_test"
)
androidx.core.app.NotificationManagerCompat
.from(context)
.apply {
cancel(1099)
cancel(2001)
cancel(3001)
cancel(3002)
cancel(3003)
cancel(3004)
}
saveScope.launch {
context.clearLCDPetWants()
context.setLCDPetOutsideEnergyNextTickAtMs(0L)
}
}
fun wakeFromAutoSleep() {
if (isSleeping && sleepMode == "Auto") {
val sleptOutside = location == "Outside"
val startedAtMs = if (autoSleepStartedAtMs > 0L) autoSleepStartedAtMs else System.currentTimeMillis()
val sleptMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
val energyGain = (sleptMs / roughSleepEnergyTickMs).toInt().coerceAtLeast(1)
energy = (energy + energyGain).coerceAtMost(10)
if (sleptOutside) {
location = "Inside"
bond = (bond - 1).coerceAtLeast(0)
}
// Keep the pet officially asleep during the dark wake-up pause.
// This prevents the UI from flashing Nervous before Rough Wake appears.
roughWakeDarkFlash = true
activityMood = "Asleep"
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
saveScope.launch {
delay(if (testingMode) 1_500L else 1_200L)
if (isSleeping && sleepMode == "Auto") {
isSleeping = false
sleepMode = ""
autoSleepStartedAtMs = 0L
autoSleepWakeAllowed = false
restoredAutoSleep = false
roughWakeDarkFlash = false
roughWakeNervousHold = false
status = "Nervous"
activityMood = "RoughWake"
roughWakeAlert = true
frameProp = "!?!"
framePropAnimation = ""
framePropStyle = 0
happiness = (happiness - 1).coerceAtLeast(0)
}
}
}
}
val currentNeedsPetName by rememberUpdatedState(needsPetName)
val currentSaveLoaded by rememberUpdatedState(saveLoaded)
val currentLocation by rememberUpdatedState(location)
val currentEnergy by rememberUpdatedState(energy)
val currentPetName by rememberUpdatedState(petName)
val currentWantForLifecycle by rememberUpdatedState(currentWant)
val currentStatusForLifecycle by rememberUpdatedState(status)
val currentIsSleeping by rememberUpdatedState(isSleeping)
val currentSleepMode by rememberUpdatedState(sleepMode)
val currentIsBathing by rememberUpdatedState(isBathing)
val currentIsFeeding by rememberUpdatedState(isFeeding)
val currentIsTradingInPet by rememberUpdatedState(isTradingInPet)
val currentIsFindingNewPet by rememberUpdatedState(isFindingNewPet)
val currentOutsideEnergyNextTickAtMs by rememberUpdatedState(
outsideEnergyNextTickAtMs
)
val currentNextWantAtMs by rememberUpdatedState(nextWantAtMs)
val currentSuppressBackgroundWant by rememberUpdatedState(
suppressBackgroundWantOnNextStop
)
val currentAutoSleepWakeAllowed by rememberUpdatedState(
autoSleepWakeAllowed
)
val currentRestoredAutoSleep by rememberUpdatedState(restoredAutoSleep)
DisposableEffect(lifecycleOwner) {
val outsideEnergyWorkName = "lcdpet_outside_energy_warning"
val backgroundWantWorkName = "lcdpet_background_want"
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
endTalk()
if (currentIsSleeping && currentSleepMode == "Auto") {
autoSleepWakeAllowed = true
}
}
Lifecycle.Event.ON_STOP -> {
endTalk()
val stopNowMs = System.currentTimeMillis()
if (currentIsSleeping && currentSleepMode == "Auto") {
autoSleepWakeAllowed = true
}
val shouldTrackOutsideEnergy =
currentSaveLoaded &&
currentLocation == "Outside" &&
!currentIsSleeping &&
!currentIsTradingInPet &&
!currentIsFindingNewPet &&
!currentNeedsPetName
if (shouldTrackOutsideEnergy) {
val nowMs = stopNowMs
val nextTickAtMs =
if (currentOutsideEnergyNextTickAtMs > nowMs) {
currentOutsideEnergyNextTickAtMs
} else {
nowMs + offlineOutsideEnergyDrainTickMs
}
outsideEnergyNextTickAtMs = nextTickAtMs
saveScope.launch {
context.setLCDPetOutsideEnergyNextTickAtMs(nextTickAtMs)
}
val warningDelayMs =
if (currentEnergy <= 1) {
0L
} else {
(nextTickAtMs - nowMs).coerceAtLeast(0L) +
((currentEnergy - 2L) *
offlineOutsideEnergyDrainTickMs)
}
val notificationPetName =
currentPetName.ifBlank { "Your pet" }
scheduleLCDPetNotificationWork(
context = context,
uniqueWorkName = outsideEnergyWorkName,
delayMs = warningDelayMs,
notificationId = 3004,
message =
"$notificationPetName is exhausted. Nap soon.",
urgentNeed = true
)
} else {
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = outsideEnergyWorkName
)
outsideEnergyNextTickAtMs = 0L
saveScope.launch {
context.setLCDPetOutsideEnergyNextTickAtMs(0L)
}
}
val suppressWantForThisStop =
currentSuppressBackgroundWant
val shouldScheduleBackgroundWant =
currentSaveLoaded &&
!suppressWantForThisStop &&
currentWantForLifecycle.isEmpty() &&
currentStatusForLifecycle !in setOf("Sick", "Weak") &&
!currentIsSleeping &&
!currentIsBathing &&
!currentIsFeeding &&
!currentIsTradingInPet &&
!currentIsFindingNewPet &&
!currentNeedsPetName
if (shouldScheduleBackgroundWant) {
val dueAtMs =
if (currentNextWantAtMs > 0L) {
currentNextWantAtMs
} else {
(stopNowMs + wantCooldownOptionsMs.random()).also {
nextWantAtMs = it
saveScope.launch {
context.setLCDPetNextWantAtMs(it)
}
}
}
scheduleLCDPetWantWork(
context = context,
uniqueWorkName = backgroundWantWorkName,
delayMs = (dueAtMs - stopNowMs).coerceAtLeast(0L)
)
} else {
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = backgroundWantWorkName
)
}
}
Lifecycle.Event.ON_RESUME -> {
val resumeNowMs = System.currentTimeMillis()
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = backgroundWantWorkName
)
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = outsideEnergyWorkName
)
val shouldApplyOutsideEnergy =
currentSaveLoaded &&
currentLocation == "Outside" &&
!currentIsSleeping &&
!currentIsTradingInPet &&
!currentIsFindingNewPet &&
!currentNeedsPetName &&
currentOutsideEnergyNextTickAtMs > 0L
if (shouldApplyOutsideEnergy) {
val nowMs = resumeNowMs
if (nowMs >= currentOutsideEnergyNextTickAtMs) {
val dueTicks =
1 + ((nowMs - currentOutsideEnergyNextTickAtMs) /
offlineOutsideEnergyDrainTickMs).toInt()
val energyBeforeCatchUp = currentEnergy
val energyAfterCatchUp =
(energyBeforeCatchUp - dueTicks)
.coerceAtLeast(0)
// Acknowledge the worker alert before changing Energy.
// This prevents the foreground watcher from observing
// Energy 1 before the warning-open flag becomes true.
if (energyAfterCatchUp <= 1) {
energyNeedNotificationOpen = true
}
if (dueTicks >= energyBeforeCatchUp) {
energy = 0
isSleeping = true
sleepMode = "Auto"
autoSleepStartedAtMs =
currentOutsideEnergyNextTickAtMs +
((energyBeforeCatchUp - 1L)
.coerceAtLeast(0L) *
offlineOutsideEnergyDrainTickMs)
outsideEnergyNextTickAtMs = 0L
autoSleepWakeAllowed = true
restoredAutoSleep = true
} else {
energy = energyAfterCatchUp
}
}
}
outsideEnergyNextTickAtMs = 0L
saveScope.launch {
context.setLCDPetOutsideEnergyNextTickAtMs(0L)
}
if (
isSleeping &&
sleepMode == "Auto" &&
(currentAutoSleepWakeAllowed || currentRestoredAutoSleep)
) {
restoredAutoSleep = true
}
if (currentSuppressBackgroundWant) {
nextWantAtMs = 0L
saveScope.launch {
context.setLCDPetNextWantAtMs(0L)
}
suppressBackgroundWantOnNextStop = false
}
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
endTalk()
}
}
LaunchedEffect(saveLoaded, restoredAutoSleep) {
if (saveLoaded && restoredAutoSleep && isSleeping && sleepMode == "Auto") {
activityMood = "Asleep"
roughWakeDarkFlash = true
if (framePropAnimation != "Nap") {
startFrameAnimation("Nap")
}
delay(if (testingMode) 1_500L else 2_000L)
if (isSleeping && sleepMode == "Auto") {
wakeFromAutoSleep()
}
}
}
LaunchedEffect(saveLoaded, restoredManualNap) {
if (
!saveLoaded ||
!restoredManualNap ||
!isSleeping ||
sleepMode != "Nap"
) {
return@LaunchedEffect
}
val wakeAtMs = autoSleepStartedAtMs
val napStillActive =
wakeAtMs <= 0L || System.currentTimeMillis() < wakeAtMs
if (!napStillActive) {
// The original Nap finished while LCDPet was closed.
// Release the gate so the normal Nap completion effect can finish it.
restoredManualNap = false
return@LaunchedEffect
}
// LCDPet was reopened before the manual Nap was finished.
roughWakeDarkFlash = true
activityMood = "Asleep"
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
delay(if (testingMode) 1_500L else 1_200L)
if (
restoredManualNap &&
isSleeping &&
sleepMode == "Nap"
) {
isSleeping = false
sleepMode = ""
autoSleepStartedAtMs = 0L
restoredManualNap = false
roughWakeDarkFlash = false
roughWakeNervousHold = false
status = "Nervous"
activityMood = "RoughWake"
roughWakeAlert = true
frameProp = "!?!"
framePropAnimation = ""
framePropStyle = 0
happiness = (happiness - 1).coerceAtLeast(0)
}
}
LaunchedEffect(
bond,
petAgeDays,
careScore,
neglectScore,
hunger,
health,
happiness,
energy,
dirtiness,
potty,
boredom,
starvingCount,
recoveryCount,
sickCount,
playEnergyCount,
sameActionStreak,
boredomTriggerAt,
boredomPenaltyCount,
ultraTrollTriggered,
lastActionName,
location,
weather,
status,
weakCause,
pottyStain,
happyOutsideTrip,
isTradingInPet,
isFindingNewPet,
tradeInProgress,
currentWant,
currentWantStartedAtMs,
nextWantAtMs,
petName,
isSleeping,
sleepMode,
roughWakeAlert,
autoSleepStartedAtMs,
nextWeatherAtMs,
bondReadyAtMs,
careCycleNextTickAtMs,
rainHappinessNextTickAtMs,
pottyNextTickAtMs,
dirtyHealthNextTickAtMs,
careJournalEntries,
saveLoaded
) {
if (saveLoaded) {
context.saveLCDPetStats(
bond = bond,
petAgeDays = petAgeDays,
careScore = careScore,
neglectScore = neglectScore,
hunger = hunger,
health = health,
happiness = happiness,
energy = energy,
dirtiness = dirtiness,
potty = potty,
boredom = boredom,
starvingCount = starvingCount,
recoveryCount = recoveryCount,
sickCount = sickCount,
playEnergyCount = playEnergyCount,
sameActionStreak = sameActionStreak,
boredomTriggerAt = boredomTriggerAt,
boredomPenaltyCount = boredomPenaltyCount,
ultraTrollTriggered = ultraTrollTriggered,
lastActionName = lastActionName,
location = location,
weather = weather,
status = status,
weakCause = weakCause,
pottyStain = pottyStain,
happyOutsideTrip = happyOutsideTrip,
isTradingInPet = isTradingInPet,
isFindingNewPet = isFindingNewPet,
tradeInProgress = tradeInProgress,
tradeInStartedAtMs = tradeInStartedAtMs,
currentWant = currentWant,
currentWantStartedAtMs = currentWantStartedAtMs,
nextWantAtMs = nextWantAtMs,
petName = petName,
petTemplateId = petTemplateId,
isSleeping = isSleeping,
sleepMode = sleepMode,
roughWakeAlert = roughWakeAlert,
autoSleepStartedAtMs = autoSleepStartedAtMs,
nextWeatherAtMs = nextWeatherAtMs,
bondReadyAtMs = bondReadyAtMs,
careCycleNextTickAtMs = careCycleNextTickAtMs,
rainHappinessNextTickAtMs = rainHappinessNextTickAtMs,
pottyNextTickAtMs = pottyNextTickAtMs,
dirtyHealthNextTickAtMs = dirtyHealthNextTickAtMs,
careJournalRaw = encodeCareJournalEntries(careJournalEntries)
)
}
}
LaunchedEffect(isTradingInPet, saveLoaded, tradeInStartedAtMs) {
if (isTradingInPet && saveLoaded) {
if (tradeInStartedAtMs <= 0L) {
val restoredProgress = tradeInProgress.coerceIn(0, tradeInProgressBlocks)
val restoredElapsedMs =
(tradeInWaitMs * restoredProgress) / tradeInProgressBlocks
tradeInStartedAtMs =
System.currentTimeMillis() - restoredElapsedMs
}
while (isTradingInPet) {
val elapsedMs =
(System.currentTimeMillis() - tradeInStartedAtMs)
.coerceAtLeast(0L)
tradeInRemainingMs =
(tradeInWaitMs - elapsedMs).coerceAtLeast(0L)
tradeInProgress =
((elapsedMs * tradeInProgressBlocks) / tradeInWaitMs)
.toInt()
.coerceIn(0, tradeInProgressBlocks)
if (tradeInRemainingMs <= 0L) {
break
}
delay(minOf(1_000L, tradeInRemainingMs))
}
if (isTradingInPet) {
tradeInProgress = tradeInProgressBlocks
tradeInRemainingMs = 0L
tradeInStartedAtMs = 0L
isTradingInPet = false
tradeInConfirm = false
statsMenuOpen = false
isFindingNewPet = true
// Force-save finding-new-pet state immediately.
context.saveLCDPetStats(
bond = bond,
petAgeDays = petAgeDays,
careScore = careScore,
neglectScore = neglectScore,
hunger = hunger,
health = health,
happiness = happiness,
energy = energy,
dirtiness = dirtiness,
potty = potty,
boredom = boredom,
starvingCount = starvingCount,
recoveryCount = recoveryCount,
sickCount = sickCount,
playEnergyCount = playEnergyCount,
sameActionStreak = sameActionStreak,
boredomTriggerAt = boredomTriggerAt,
boredomPenaltyCount = boredomPenaltyCount,
ultraTrollTriggered = ultraTrollTriggered,
lastActionName = lastActionName,
location = location,
weather = weather,
status = status,
weakCause = weakCause,
pottyStain = pottyStain,
happyOutsideTrip = happyOutsideTrip,
isTradingInPet = false,
isFindingNewPet = true,
tradeInProgress = tradeInProgressBlocks,
tradeInStartedAtMs = 0L,
currentWant = currentWant,
currentWantStartedAtMs = currentWantStartedAtMs,
nextWantAtMs = nextWantAtMs,
petName = petName,
petTemplateId = petTemplateId,
isSleeping = isSleeping,
sleepMode = sleepMode,
roughWakeAlert = roughWakeAlert,
autoSleepStartedAtMs = autoSleepStartedAtMs,
nextWeatherAtMs = nextWeatherAtMs,
bondReadyAtMs = bondReadyAtMs,
careCycleNextTickAtMs = careCycleNextTickAtMs,
rainHappinessNextTickAtMs = rainHappinessNextTickAtMs,
pottyNextTickAtMs = pottyNextTickAtMs,
dirtyHealthNextTickAtMs = dirtyHealthNextTickAtMs,
careJournalRaw = encodeCareJournalEntries(careJournalEntries)
)
}
}
}
LaunchedEffect(isFindingNewPet) {
if (isFindingNewPet) {
delay(findingNewPetMs)
resetPetAfterTradeIn()
}
}
LaunchedEffect(roughWakeAlert) {
if (roughWakeAlert) {
delay(roughWakeAlertMs)
roughWakeAlert = false
if (activityMood == "RoughWake") {
activityMood = ""
}
if (frameProp == "!?!") {
frameProp = ""
}
roughWakeNervousHold = true
}
}
LaunchedEffect(roughWakeNervousHold) {
if (roughWakeNervousHold) {
delay(if (testingMode) 10_000L else 10_000L)
roughWakeNervousHold = false
}
}
LaunchedEffect(status, saveLoaded) {
if (saveLoaded && status in setOf("Sick", "Weak")) {
currentWant = ""
currentWantStartedAtMs = 0L
nextWantAtMs = 0L
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_background_want"
)
context.clearLCDPetWants()
}
}
LaunchedEffect(
currentWant,
location,
status,
isSleeping,
isBathing,
isFeeding,
suppressBackgroundWantOnNextStop,
isTradingInPet,
isFindingNewPet,
needsPetName,
saveLoaded
) {
if (
!saveLoaded ||
suppressBackgroundWantOnNextStop ||
status in setOf("Sick", "Weak") ||
isFeeding ||
isTradingInPet ||
isFindingNewPet ||
needsPetName
) {
return@LaunchedEffect
}
if (
currentWant.isEmpty() &&
!isSleeping &&
!isBathing &&
!isFeeding &&
!suppressBackgroundWantOnNextStop
) {
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (nextWantAtMs > 0L) {
nextWantAtMs
} else {
(nowMs + wantCooldownOptionsMs.random()).also {
nextWantAtMs = it
saveScope.launch {
context.setLCDPetNextWantAtMs(it)
}
}
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
if (
currentWant.isEmpty() &&
!isSleeping &&
!isBathing &&
!isFeeding &&
!suppressBackgroundWantOnNextStop &&
status !in setOf("Sick", "Weak") &&
!isTradingInPet &&
!isFindingNewPet &&
!needsPetName
) {
val claimNowMs = System.currentTimeMillis()
val createdWant =
context.createLCDPetWantIfDue(claimNowMs)
if (createdWant != null) {
currentWant = createdWant.wantName
currentWantStartedAtMs = claimNowMs
nextWantAtMs = 0L
sendWantNotification(createdWant.wantName)
}
}
} else if (currentWant.isNotEmpty()) {
if (nextWantAtMs != 0L) {
nextWantAtMs = 0L
saveScope.launch {
context.setLCDPetNextWantAtMs(0L)
}
}
val expiringWant = currentWant
val nowMs = System.currentTimeMillis()
val startedAtMs =
if (currentWantStartedAtMs > 0L) {
currentWantStartedAtMs
} else {
nowMs.also { currentWantStartedAtMs = it }
}
val elapsedMs = (nowMs - startedAtMs).coerceAtLeast(0L)
val remainingMs = (wantDurationMs - elapsedMs).coerceAtLeast(0L)
delay(remainingMs)
if (currentWant == expiringWant) {
logWantJournal(expiringWant, met = false)
currentWant = ""
currentWantStartedAtMs = 0L
}
}
}
LaunchedEffect(
weather,
isTradingInPet,
isFindingNewPet,
needsPetName,
saveLoaded
) {
if (
!saveLoaded ||
isTradingInPet ||
isFindingNewPet ||
needsPetName
) {
return@LaunchedEffect
}
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (nextWeatherAtMs > 0L) {
nextWeatherAtMs
} else {
nowMs + weatherShiftOptionsMs.random()
}
if (nextWeatherAtMs != dueAtMs) {
nextWeatherAtMs = dueAtMs
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
if (
!isTradingInPet &&
!isFindingNewPet &&
!needsPetName
) {
nextWeatherAtMs = 0L
weather = nextWeather(weather)
}
}
LaunchedEffect(bondReady, saveLoaded) {
if (!saveLoaded || bondReady) {
return@LaunchedEffect
}
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (bondReadyAtMs > 0L) {
bondReadyAtMs
} else {
nowMs + bondCooldownMs
}
if (bondReadyAtMs != dueAtMs) {
bondReadyAtMs = dueAtMs
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
if (!bondReady) {
bondReadyAtMs = 0L
bondReady = true
}
}
LaunchedEffect(hunger, starvingCount, status, isSleeping, isTradingInPet, isFindingNewPet, needsPetName, saveLoaded) {
val notificationPetName = petName.ifBlank { "Your pet" }
if (!saveLoaded || isSleeping || isTradingInPet || isFindingNewPet || needsPetName) {
return@LaunchedEffect
}
val hungerWarningThresholdOpen =
hunger >= 10 && starvingCount >= 2
if (!hungerWarningThresholdOpen) {
hungerNeedNotificationOpen = false
} else if (status in listOf("Sick", "Weak", "Recovering", "RecoverWeak")) {
// Sickness already communicates the unresolved condition. Keep
// the warning consumed until Hunger actually drops below its
// threshold so status changes cannot re-arm the same alert.
hungerNeedNotificationOpen = true
} else if (!hungerNeedNotificationOpen) {
hungerNeedNotificationOpen = true
sendNeedNotification(
notificationId = 3001,
message = "$notificationPetName is really hungry. Feed soon."
)
}
}
LaunchedEffect(location, potty, isSleeping, isTradingInPet, isFindingNewPet, needsPetName, saveLoaded) {
val notificationPetName = petName.ifBlank { "Your pet" }
if (!saveLoaded || isSleeping || isTradingInPet || isFindingNewPet || needsPetName) {
return@LaunchedEffect
}
if (location == "Inside" && potty >= 9 && !pottyNeedNotificationOpen) {
pottyNeedNotificationOpen = true
sendNeedNotification(
notificationId = 3002,
message = "$notificationPetName has to potty soon."
)
} else if (location != "Inside" || potty < 9) {
pottyNeedNotificationOpen = false
}
}
LaunchedEffect(dirtiness, status, isSleeping, isTradingInPet, isFindingNewPet, needsPetName, saveLoaded) {
val notificationPetName = petName.ifBlank { "Your pet" }
if (!saveLoaded || isSleeping || isTradingInPet || isFindingNewPet || needsPetName) {
return@LaunchedEffect
}
if (
dirtiness >= 9 &&
status !in listOf("Sick", "Weak", "Recovering", "RecoverWeak") &&
!dirtinessNeedNotificationOpen
) {
dirtinessNeedNotificationOpen = true
sendNeedNotification(
notificationId = 3003,
message = "$notificationPetName needs a bath soon."
)
} else if (dirtiness < 9 || status in listOf("Sick", "Weak", "Recovering", "RecoverWeak")) {
dirtinessNeedNotificationOpen = false
}
}
LaunchedEffect(energy, isSleeping, isBathing, isTalking, isTradingInPet, isFindingNewPet, needsPetName, saveLoaded) {
val notificationPetName = petName.ifBlank { "Your pet" }
if (!saveLoaded || isSleeping || isBathing || isTalking || isTradingInPet || isFindingNewPet || needsPetName) {
return@LaunchedEffect
}
if (energy <= 1 && !energyNeedNotificationOpen) {
energyNeedNotificationOpen = true
sendNeedNotification(
notificationId = 3004,
message = "$notificationPetName is exhausted. Nap soon."
)
} else if (energy > 1 || isSleeping) {
energyNeedNotificationOpen = false
}
}
LaunchedEffect(
hunger,
starvingCount,
status,
recoveryCount,
sickCount,
happiness,
isSleeping,
isTradingInPet,
isFindingNewPet,
needsPetName,
saveLoaded
) {
if (
!saveLoaded ||
isSleeping ||
isTradingInPet ||
isFindingNewPet ||
needsPetName
) {
if (careCycleNextTickAtMs != 0L) {
careCycleNextTickAtMs = 0L
}
return@LaunchedEffect
}
val tickIntervalMs =
when (status) {
"Sick" -> sickTickMs
"Weak" -> weakRecoveryCheckMs
"RecoverWeak" -> recoverWeakMs
"Recovering" -> recoveringTickMs
else -> hungerTickMs
}
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (careCycleNextTickAtMs > 0L) {
careCycleNextTickAtMs
} else {
nowMs + tickIntervalMs
}
if (careCycleNextTickAtMs != dueAtMs) {
careCycleNextTickAtMs = dueAtMs
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
if (
isSleeping ||
isTradingInPet ||
isFindingNewPet ||
needsPetName
) {
careCycleNextTickAtMs = 0L
return@LaunchedEffect
}
// The completed tick consumes this deadline. Any resulting state
// change starts a fresh interval for the new/current care branch.
careCycleNextTickAtMs = 0L
if (status == "Sick") {
sickCount += 1
damageHealth()
if (hunger < 10) {
hunger += 1
}
if (status == "Sick" && weakCause == "Overfed" && hunger > 0) {
status = "Recovering"
recoveryCount = 0
}
} else if (status == "Weak") {
if (location == "Inside" && energy > 0 && hunger < 10) {
status = "RecoverWeak"
health = 1
}
} else if (status == "RecoverWeak") {
if (location == "Inside" && hunger < 10) {
status = "Calm"
recoveryCount = 0
overfeedCount = 0
starvingCount = 0
sickCount = 0
weakCause = ""
}
} else if (status == "Recovering") {
recoveryCount += 1
if (recoveryCount >= recoveryNeeded && hunger < 10) {
status = "Calm"
recoveryCount = 0
overfeedCount = 0
starvingCount = 0
sickCount = 0
weakCause = ""
}
} else {
if (hunger < 10) {
hunger += 1
overfeedCount = 0
overfeedNeedWindowOpen = false
if (hunger < 10) {
starvingCount = 0
hungerNeedWindowOpen = false
}
} else {
starvingCount += 1
if (starvingCount >= 3 && !hungerNeedWindowOpen) {
logNeedJournal("Hunger", met = false)
hungerNeedWindowOpen = true
neglectScore = (neglectScore + 1).coerceAtMost(999)
weakCause = "Starving"
val newHealth = (health - 1).coerceAtLeast(0)
health = newHealth
status = if (newHealth == 0) "Weak" else "Sick"
if (status == "Sick") {
playPetSound(R.raw.pet_sick)
}
}
}
}
}
LaunchedEffect(
location,
weather,
happiness,
happyOutsideTrip,
isTradingInPet,
isFindingNewPet,
needsPetName,
saveLoaded
) {
val rainPenaltyActive =
saveLoaded &&
!isTradingInPet &&
!isFindingNewPet &&
!needsPetName &&
location == "Outside" &&
weather == "Rain" &&
happiness > 0 &&
!happyOutsideTrip
if (!rainPenaltyActive) {
if (rainHappinessNextTickAtMs != 0L) {
rainHappinessNextTickAtMs = 0L
}
return@LaunchedEffect
}
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (rainHappinessNextTickAtMs > 0L) {
rainHappinessNextTickAtMs
} else {
nowMs + rainHappinessTickMs
}
if (rainHappinessNextTickAtMs != dueAtMs) {
rainHappinessNextTickAtMs = dueAtMs
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
if (
location == "Outside" &&
weather == "Rain" &&
happiness > 0 &&
!happyOutsideTrip &&
!isTradingInPet &&
!isFindingNewPet &&
!needsPetName
) {
rainHappinessNextTickAtMs = 0L
happiness = (happiness - 1).coerceAtLeast(0)
}
}
LaunchedEffect(
location,
potty,
isSleeping,
isTradingInPet,
isFindingNewPet,
needsPetName,
saveLoaded
) {
if (
!saveLoaded ||
isSleeping ||
isTradingInPet ||
isFindingNewPet ||
needsPetName
) {
if (pottyNextTickAtMs != 0L) {
pottyNextTickAtMs = 0L
}
return@LaunchedEffect
}
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (pottyNextTickAtMs > 0L) {
pottyNextTickAtMs
} else {
nowMs + pottyTickMs
}
if (pottyNextTickAtMs != dueAtMs) {
pottyNextTickAtMs = dueAtMs
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
if (
isSleeping ||
isTradingInPet ||
isFindingNewPet ||
needsPetName
) {
pottyNextTickAtMs = 0L
return@LaunchedEffect
}
pottyNextTickAtMs = 0L
if (potty < 10) {
potty = (potty + 1).coerceAtMost(10)
} else if (location == "Inside") {
logNeedJournal("Potty", met = false)
neglectScore = (neglectScore + 1).coerceAtMost(999)
choosePottyFace()
startFrameAnimation("PottyAccident")
potty = 0
dirtiness = (dirtiness + 3).coerceAtMost(10)
} else {
pottyStain = ""
choosePottyFace()
startFrameAnimation("PottyClean")
potty = 0
damageHealth()
}
}
LaunchedEffect(
dirtiness,
status,
isSleeping,
isTradingInPet,
isFindingNewPet,
needsPetName,
saveLoaded
) {
val dirtyHealthActive =
saveLoaded &&
!isSleeping &&
!isTradingInPet &&
!isFindingNewPet &&
!needsPetName &&
dirtiness >= 10 &&
status !in listOf(
"Sick",
"Weak",
"Recovering",
"RecoverWeak"
)
if (!dirtyHealthActive) {
if (dirtyHealthNextTickAtMs != 0L) {
dirtyHealthNextTickAtMs = 0L
}
return@LaunchedEffect
}
val nowMs = System.currentTimeMillis()
val dueAtMs =
if (dirtyHealthNextTickAtMs > 0L) {
dirtyHealthNextTickAtMs
} else {
nowMs + dirtyHealthTickMs
}
if (dirtyHealthNextTickAtMs != dueAtMs) {
dirtyHealthNextTickAtMs = dueAtMs
}
delay((dueAtMs - nowMs).coerceAtLeast(0L))
if (
dirtiness >= 10 &&
status !in listOf(
"Sick",
"Weak",
"Recovering",
"RecoverWeak"
) &&
!isSleeping &&
!isTradingInPet &&
!isFindingNewPet &&
!needsPetName
) {
dirtyHealthNextTickAtMs = 0L
logNeedJournal("Bath", met = false)
neglectScore = (neglectScore + 1).coerceAtMost(999)
weakCause = "Dirty"
val newHealth = damageHealth()
if (newHealth > 0) {
status = "Sick"
playPetSound(R.raw.pet_sick)
}
}
}
LaunchedEffect(isFeeding) {
if (isFeeding) {
val mealTarget = 2
val mealBars =
(feedingStartHunger - mealTarget).coerceAtLeast(0)
repeat(mealBars) { step ->
delay(feedStepMs)
if (!isFeeding) {
return@LaunchedEffect
}
hunger = (hunger - 1).coerceAtLeast(mealTarget)
val barsRemaining = mealBars - step - 1
frameProp = when {
barsRemaining >= 6 -> "[###]"
barsRemaining >= 3 -> "[## ]"
barsRemaining >= 1 -> "[# ]"
else -> "[___]"
}
}
val energyGain =
((mealBars + 2) / 3).coerceIn(1, 3)
val pottyGain =
(mealBars / 3).coerceIn(0, 2)
energy = (energy + energyGain).coerceAtMost(10)
potty = (potty + pottyGain).coerceAtMost(10)
happiness =
(happiness + if (mealBars >= 3) 1 else 0)
.coerceAtMost(10)
logNeedJournal("Feed", met = true)
starvingCount = 0
overfeedCount = 0
hungerNeedWindowOpen = false
overfeedNeedWindowOpen = false
careScore = (careScore + 1).coerceAtMost(999)
buildBond()
if (status == "Sick") {
careCycleNextTickAtMs = 0L
status = "Recovering"
recoveryCount = 0
}
delay(if (testingMode) 450L else 1_000L)
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
activityMood = ""
isFeeding = false
feedingStartHunger = 0
}
}
LaunchedEffect(activityMood, isBathing, isFeeding) {
if (activityMood.isNotEmpty() && !isBathing && !isFeeding) {
delay(activityMoodMs)
activityMood = ""
}
}
LaunchedEffect(frameProp, framePropAnimation, isTalking) {
if (frameProp.isNotEmpty() && framePropAnimation.isEmpty() && !isTalking) {
delay(activityMoodMs)
frameProp = ""
}
}
LaunchedEffect(framePropAnimation, framePropAnimationId, isSleeping, isBathing) {
when (framePropAnimation) {
"Ball" -> {
val frames = listOf(
"o ",
" o ",
" o ",
" o ",
" o",
" o ",
" o ",
" o "
)
repeat(2) {
for (frame in frames) {
frameProp = frame
framePropStyle = 0
delay(propAnimationTickMs)
}
}
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
}
"Hand" -> {
val frames = listOf(
"\\☻/",
"-☻/",
"/☻\\",
"\\☻-"
)
repeat(3) {
for (frame in frames) {
frameProp = frame
framePropStyle = 0
delay(propAnimationTickMs)
}
}
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
}
"Talk" -> {
val frames = listOf(
"¢==-",
"¢==-)",
"¢==-))",
"¢==-=)"
)
repeat(3) {
for (frame in frames) {
frameProp = frame
framePropStyle = 0
delay(propAnimationTickMs)
}
}
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
}
"Footprints" -> {
val frames = listOf(
". . ",
" . . ",
" . . ",
" . .",
" . . ",
" . . "
)
repeat(2) {
for (frame in frames) {
frameProp = frame
framePropStyle = 0
delay(propAnimationTickMs)
}
}
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
}
"PottyClean" -> {
val frames = listOf(
" . ",
" : ",
" ... ",
" . "
)
repeat(2) {
for (frame in frames) {
frameProp = frame
framePropStyle = 0
delay(propAnimationTickMs)
}
}
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
pottyFaceOverride = ""
}
"PottyAccident" -> {
val frames = listOf(
" . ",
" : ",
" ... ",
" ..... "
)
for (frame in frames) {
frameProp = frame
framePropStyle = 0
delay(propAnimationTickMs)
}
frameProp = ""
pottyStain = " ..... "
framePropAnimation = ""
framePropStyle = 0
pottyFaceOverride = ""
}
"Nap" -> {
while (isSleeping && framePropAnimation == "Nap") {
frameProp = "ZzZ"
framePropStyle = 0
delay(propAnimationTickMs * 2)
frameProp = "ZzZ"
framePropStyle = 1
delay(propAnimationTickMs * 2)
}
if (!isSleeping) {
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
pottyFaceOverride = ""
}
}
"Bath" -> {
while (isBathing && framePropAnimation == "Bath") {
frameProp = "≈≈≈"
framePropStyle = 0
delay(propAnimationTickMs)
frameProp = "≈≈≈"
framePropStyle = 1
delay(propAnimationTickMs)
frameProp = "≈≈≈"
framePropStyle = 2
delay(propAnimationTickMs)
}
if (!isBathing) {
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
}
}
}
}
LaunchedEffect(isBathing) {
if (isBathing) {
isTalking = false
val bathDuration = when {
bathBondAtStart < 30 -> lowBondBathMs
bathBondAtStart < 60 -> mediumBondBathMs
else -> trustedBathMs
}
delay(bathDuration)
val dirtinessBeforeBath = dirtiness
when {
bathBondAtStart < 30 -> {
dirtiness = (dirtinessBeforeBath - 3).coerceAtLeast(0)
happiness = (happiness - 1).coerceAtLeast(0)
}
bathBondAtStart < 60 -> {
dirtiness = (dirtinessBeforeBath - 6).coerceAtLeast(0)
}
bathBondAtStart < 90 -> {
dirtiness = 0
}
else -> {
dirtiness = 0
happiness = (happiness + 1).coerceAtMost(10)
}
}
if (bathWantMatched) {
dirtiness = 0
energy = (energy + 2).coerceAtMost(10)
happiness = 10
boredom = 0
bathWantMatched = false
}
if (dirtiness < dirtinessBeforeBath) {
dirtyHealthNextTickAtMs = 0L
careScore = (careScore + 1).coerceAtMost(999)
}
if (weakCause == "Dirty" && status == "Sick" && dirtiness < 10) {
careCycleNextTickAtMs = 0L
status = "Recovering"
recoveryCount = 0
}
buildBond()
isBathing = false
activityMood = ""
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
pottyStain = ""
}
}
LaunchedEffect(
energy,
isSleeping,
isBathing,
isTalking,
isTradingInPet,
isFindingNewPet,
framePropAnimation,
saveLoaded
) {
if (
saveLoaded &&
energy <= 0 &&
!isSleeping &&
!isBathing &&
!isTalking &&
!isTradingInPet &&
!isFindingNewPet &&
framePropAnimation.isEmpty()
) {
delay(autoSleepDelayMs)
if (
energy <= 0 &&
!isSleeping &&
!isBathing &&
!isTalking &&
!isTradingInPet &&
!isFindingNewPet &&
framePropAnimation.isEmpty()
) {
autoSleepWakeAllowed = false
autoSleepStartedAtMs = System.currentTimeMillis()
sleepMode = "Auto"
isSleeping = true
activityMood = "Asleep"
startFrameAnimation("Nap")
// Force-save auto-sleep start immediately so reopening resumes asleep.
saveScope.launch {
context.saveLCDPetStats(
bond = bond,
petAgeDays = petAgeDays,
careScore = careScore,
neglectScore = neglectScore,
hunger = hunger,
health = health,
happiness = happiness,
energy = energy,
dirtiness = dirtiness,
potty = potty,
boredom = boredom,
starvingCount = starvingCount,
recoveryCount = recoveryCount,
sickCount = sickCount,
playEnergyCount = playEnergyCount,
sameActionStreak = sameActionStreak,
boredomTriggerAt = boredomTriggerAt,
boredomPenaltyCount = boredomPenaltyCount,
ultraTrollTriggered = ultraTrollTriggered,
lastActionName = lastActionName,
location = location,
weather = weather,
status = status,
weakCause = weakCause,
pottyStain = pottyStain,
happyOutsideTrip = happyOutsideTrip,
isTradingInPet = isTradingInPet,
isFindingNewPet = isFindingNewPet,
tradeInProgress = tradeInProgress,
tradeInStartedAtMs = tradeInStartedAtMs,
currentWant = currentWant,
currentWantStartedAtMs = currentWantStartedAtMs,
nextWantAtMs = nextWantAtMs,
petName = petName,
petTemplateId = petTemplateId,
isSleeping = true,
sleepMode = "Auto",
roughWakeAlert = roughWakeAlert,
autoSleepStartedAtMs = autoSleepStartedAtMs,
nextWeatherAtMs = nextWeatherAtMs,
bondReadyAtMs = bondReadyAtMs,
careCycleNextTickAtMs = careCycleNextTickAtMs,
rainHappinessNextTickAtMs = rainHappinessNextTickAtMs,
pottyNextTickAtMs = pottyNextTickAtMs,
dirtyHealthNextTickAtMs = dirtyHealthNextTickAtMs,
careJournalRaw = encodeCareJournalEntries(careJournalEntries)
)
}
}
}
}
LaunchedEffect(
isSleeping,
sleepMode,
saveLoaded,
restoredManualNap
) {
if (
!saveLoaded ||
!isSleeping ||
sleepMode != "Nap" ||
restoredManualNap
) {
return@LaunchedEffect
}
isTalking = false
val nowMs = System.currentTimeMillis()
val wakeAtMs =
if (autoSleepStartedAtMs > 0L) {
autoSleepStartedAtMs
} else {
// Compatibility for an older saved manual Nap with no deadline.
(nowMs + sleepOptionsMs.random()).also {
autoSleepStartedAtMs = it
}
}
val remainingSleepMs = (wakeAtMs - nowMs).coerceAtLeast(0L)
val sleepSteps = 5
val stepDelay = remainingSleepMs / sleepSteps
val startHealth = health
val startHappiness = happiness
val startEnergy = energy
val startHunger = hunger
val startPotty = potty
repeat(sleepSteps) { step ->
delay(stepDelay)
val progress = step + 1
health = startHealth + ((10 - startHealth) * progress / sleepSteps)
happiness = startHappiness + ((10 - startHappiness) * progress / sleepSteps)
energy = startEnergy + ((10 - startEnergy) * progress / sleepSteps)
if (startHunger < 5) {
hunger = startHunger + ((5 - startHunger) * progress / sleepSteps)
}
if (startPotty < 7) {
potty = startPotty + ((7 - startPotty) * progress / sleepSteps)
}
}
health = 10
happiness = 10
energy = 10
if (status in listOf("Sick", "Weak", "Recovering", "RecoverWeak")) {
careCycleNextTickAtMs = 0L
status = "Calm"
weakCause = ""
overfeedCount = 0
starvingCount = 0
overfeedNeedWindowOpen = false
hungerNeedWindowOpen = false
recoveryCount = 0
sickCount = 0
}
isSleeping = false
sleepMode = ""
autoSleepStartedAtMs = 0L
activityMood = ""
if (framePropAnimation == "Nap") {
frameProp = ""
framePropAnimation = ""
framePropStyle = 0
}
}
val screenScrollState = rememberScrollState()
val configuration = LocalConfiguration.current
val widthScale =
(configuration.screenWidthDp / 400f)
.coerceIn(0.80f, 1f)
val heightScale =
(configuration.screenHeightDp / 780f)
.coerceIn(0.74f, 1f)
val layoutScale = minOf(widthScale, heightScale)
val screenPadding = (16f * layoutScale).dp
val petFrameMinHeight = (360f * layoutScale).dp
val petFrameOuterPadding = (5f * layoutScale).dp
val petFrameInnerPadding = (6f * layoutScale).dp
val meterTopPadding = (8f * layoutScale).dp
val meterSpacing = (3f * layoutScale).dp
val controlsTopSpacing = (6f * layoutScale).dp
val actionSpacing = (8f * layoutScale).dp
CompositionLocalProvider(LocalContentColor provides lcdDark) {
Box(
modifier = modifier
.fillMaxSize()
.background(petHomeBackground)
) {
val howToTopPadding = (28f * layoutScale * layoutScale).dp
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(screenScrollState)
.padding(screenPadding)
) {
if (!needsPetName) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = displayPetName,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable(
enabled =
!isTradingInPet &&
!isFindingNewPet
) {
infoPanelType = "Renovation"
infoPanelOpen = true
statsMenuOpen = false
careJournalOpen = false
testWantMenuOpen = false
testBondMenuOpen = false
careMenuOpen = false
socializeMenuOpen = false
}
)
Text(
text = if (statsMenuOpen) "Stats ▲" else "Stats ▼",
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable {
statsMenuOpen = !statsMenuOpen
if (statsMenuOpen) {
careMenuOpen = false
socializeMenuOpen = false
infoPanelOpen = false
} else {
careJournalOpen = false
testWantMenuOpen = false
testBondMenuOpen = false
}
}
)
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "$location | $weather",
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable(
enabled = downtimeMode == DowntimeMode.None
) {
toggleLocation()
}
)
Text(
text = "Extras",
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable {
infoPanelType = "Extras"
infoPanelOpen = true
statsMenuOpen = false
careJournalOpen = false
testWantMenuOpen = false
testBondMenuOpen = false
careMenuOpen = false
socializeMenuOpen = false
}
)
}
}
}
if (needsPetName) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 6.dp)
.border(2.dp, lcdDark)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "CHOOSE NEW PET",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "PET TEMPLATE",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Selected: ${pendingPetTemplate.label}",
color = lcdDark,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
lcdPetTemplates.chunked(2).forEach { rowTemplates ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
rowTemplates.forEach { template ->
val isSelectedTemplate = pendingPetTemplateId == template.id
Box(
modifier = Modifier
.weight(1f)
.border(
width = if (isSelectedTemplate) 2.dp else 1.dp,
color = if (isSelectedTemplate) lcdDark else lcdFaded
)
.clickable {
pendingPetTemplateId = template.id
}
.padding(vertical = 4.dp, horizontal = 2.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = renderPetTemplateText(template, "^_^"),
fontSize = 11.sp,
lineHeight = 11.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
color = lcdDark
)
Text(
text = template.label.uppercase(),
fontSize = 10.sp,
lineHeight = 10.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = lcdDark
)
}
}
}
}
}
Text(
text = "NAME PET",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Box(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, lcdDark)
.padding(6.dp)
) {
BasicTextField(
value = pendingPetName,
onValueChange = { typed ->
pendingPetName = typed.filter { it.isLetter() }.take(12)
},
singleLine = true,
textStyle = TextStyle(
color = lcdDark,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
),
cursorBrush = SolidColor(lcdDark),
modifier = Modifier.fillMaxWidth()
)
if (pendingPetName.isBlank()) {
Text(
text = "type name...",
color = lcdFaded,
fontWeight = FontWeight.Bold
)
}
}
if (isReservedPetName) {
Text(
text = "reserved name",
color = lcdFaded,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
LCDSubButton(
text = "KEEP NAME",
enabled = canKeepPetName,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
petTemplateId = pendingPetTemplateId
petName = trimmedPendingPetName.take(12)
pendingPetName = petName
statsMenuOpen = false
careMenuOpen = false
socializeMenuOpen = false
}
)
}
return@Column
}
if (statsMenuOpen) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp, bottom = 4.dp)
.border(2.dp, lcdDark)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Bond",
fontWeight = FontWeight.Bold
)
Row {
Text(
text = "Time",
modifier = Modifier.width(104.dp),
maxLines = 1,
softWrap = false
)
Text(text = petAgeText)
}
Row {
Text(
text = "Strength",
modifier = Modifier.width(104.dp),
maxLines = 1,
softWrap = false
)
Text(text = "$bondPercent%")
}
Row {
Text(
text = "Status",
modifier = Modifier.width(104.dp),
maxLines = 1,
softWrap = false
)
Text(text = bondRelationship)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "PET MANAGEMENT",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (isTradingInPet) {
Text(
text = "Youll need to wait before a new pet can be found.",
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "New pet search begins in ${tradeCountdownText(tradeInRemainingMs)}",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "[$tradeInProgressBar]",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "You can still change your mind.",
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
LCDSubButton(
text = "CHANGE MIND",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
cancelTradeInPet()
}
)
} else {
LCDSubButton(
text = "SET PET FREE",
enabled = !isFindingNewPet,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
beginTradeInPet()
}
)
if (testingMode) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "TESTING TOOLS",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
LCDSubButton(
text = "TEST NOTIFICATION",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
sendTestNotification()
}
)
LCDSubButton(
text = "TEST BACKGROUND 30S",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
scheduleLCDPetBackgroundTest(context)
}
)
LCDSubButton(
text = "TEST HUNGER WARNING",
enabled = !isFindingNewPet && !isTradingInPet && !isSleeping,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
hunger = 10
starvingCount = 2
hungerNeedNotificationOpen = false
}
)
LCDSubButton(
text = "TEST POTTY WARNING",
enabled = !isFindingNewPet && !isTradingInPet && !isSleeping,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
location = "Inside"
potty = 9
pottyNeedNotificationOpen = false
}
)
LCDSubButton(
text = "TEST BATH WARNING",
enabled = !isFindingNewPet && !isTradingInPet && !isSleeping,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
dirtiness = 9
dirtinessNeedNotificationOpen = false
}
)
LCDSubButton(
text = "TEST BG ENERGY 30S",
enabled = !isFindingNewPet && !isTradingInPet && !isSleeping,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
// Isolate the real 30-second Energy worker test
// from both foreground and background Wants.
suppressBackgroundWantOnNextStop = true
currentWant = ""
currentWantStartedAtMs = 0L
nextWantAtMs = 0L
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_background_want"
)
cancelLCDPetNotificationWork(
context = context,
uniqueWorkName = "lcdpet_background_test"
)
androidx.core.app.NotificationManagerCompat
.from(context)
.apply {
cancel(1099)
cancel(2001)
}
saveScope.launch {
context.clearLCDPetWants()
}
location = "Outside"
outsideEnergyNextTickAtMs = 0L
energy = 2
energyNeedNotificationOpen = false
}
)
LCDSubButton(
text = "CLEAN TEST PET",
enabled = !isFindingNewPet,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
cleanTestPet()
}
)
LCDSubButton(
text = if (testBondMenuOpen) "TEST BOND ▲" else "TEST BOND ▼",
enabled = !isFindingNewPet && !isTradingInPet,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
testBondMenuOpen = !testBondMenuOpen
if (testBondMenuOpen) {
testWantMenuOpen = false
careJournalOpen = false
infoPanelOpen = false
}
}
)
if (testBondMenuOpen) {
LCDSubButton(
text = "SET BOND 0",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { bond = 0 }
)
LCDSubButton(
text = "SET BOND 50",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { bond = 50 }
)
LCDSubButton(
text = "SET BOND 80",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { bond = 80 }
)
LCDSubButton(
text = "SET BOND 100",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { bond = 100 }
)
}
LCDSubButton(
text = if (testWantMenuOpen) "TEST WANTS ▲" else "TEST WANTS ▼",
enabled = !isFindingNewPet && !isTradingInPet && !isSleeping && !isBathing,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
testWantMenuOpen = !testWantMenuOpen
if (testWantMenuOpen) {
testBondMenuOpen = false
careJournalOpen = false
infoPanelOpen = false
}
}
)
if (testWantMenuOpen) {
LCDSubButton(
text = "FORCE SNACK WANT",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { forceTestWant("Snack") }
)
LCDSubButton(
text = "FORCE PLAY WANT",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { forceTestWant("Play") }
)
LCDSubButton(
text = "FORCE PET WANT",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { forceTestWant("Pet") }
)
LCDSubButton(
text = "FORCE TALK WANT",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { forceTestWant("Talk") }
)
LCDSubButton(
text = "FORCE OUTSIDE WANT",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { forceTestWant("Outside") }
)
LCDSubButton(
text = "FORCE BATH WANT",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { forceTestWant("Bath") }
)
LCDSubButton(
text = "TEST SNOW",
enabled = !isSleeping &&
!isBathing &&
!isTalking &&
framePropAnimation.isEmpty(),
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
weather = "Snow"
location = "Inside"
happyOutsideTrip = false
activityMood = ""
downtimeMode = DowntimeMode.None
careMenuOpen = false
socializeMenuOpen = false
testWantMenuOpen = false
statsMenuOpen = false
}
)
LCDSubButton(
text = "TEST TOUCH GRASS",
enabled = !isSleeping &&
!isBathing &&
!isTalking &&
framePropAnimation.isEmpty(),
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
downtimeMode = DowntimeMode.TouchGrass
location = "Outside"
happyOutsideTrip = false
careMenuOpen = false
socializeMenuOpen = false
testWantMenuOpen = false
statsMenuOpen = false
}
)
LCDSubButton(
text = "TEST WATCH TV",
enabled = !isSleeping &&
!isBathing &&
!isTalking &&
framePropAnimation.isEmpty(),
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
downtimeMode = DowntimeMode.WatchTv
selectedTvChannel = TvChannel.Weather
location = "Inside"
happyOutsideTrip = false
careMenuOpen = false
socializeMenuOpen = false
testWantMenuOpen = false
statsMenuOpen = false
}
)
LCDSubButton(
text = "CLEAR DOWNTIME",
enabled = downtimeMode != DowntimeMode.None,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
downtimeMode = DowntimeMode.None
testWantMenuOpen = false
}
)
LCDSubButton(
text = "TEST POTTY FACE",
enabled = !isSleeping && !isBathing && framePropAnimation.isEmpty(),
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
location = "Outside"
weather = "Sunny"
happyOutsideTrip = false
if (status !in listOf("Sick", "Recovering", "Weak", "RecoverWeak")) {
status = "Calm"
}
usePotty()
testWantMenuOpen = false
statsMenuOpen = false
}
)
LCDSubButton(
text = "TEST POTTY ACCIDENT",
enabled = !isSleeping && !isBathing && framePropAnimation.isEmpty(),
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
location = "Inside"
happyOutsideTrip = false
if (status !in listOf("Sick", "Recovering", "Weak", "RecoverWeak")) {
status = "Calm"
}
choosePottyFace()
startFrameAnimation("PottyAccident")
pottyNextTickAtMs = 0L
potty = 0
dirtiness = (dirtiness + 3).coerceAtMost(10)
testWantMenuOpen = false
statsMenuOpen = false
}
)
LCDSubButton(
text = "TEST POTTY OVERFLOW",
enabled = !isSleeping && !isBathing && framePropAnimation.isEmpty(),
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
location = "Outside"
weather = "Sunny"
happyOutsideTrip = false
if (status !in listOf("Sick", "Recovering", "Weak", "RecoverWeak")) {
status = "Calm"
}
pottyStain = ""
choosePottyFace()
startFrameAnimation("PottyClean")
pottyNextTickAtMs = 0L
potty = 0
testWantMenuOpen = false
statsMenuOpen = false
}
)
LCDSubButton(
text = "CLEAR WANT",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
currentWant = ""
currentWantStartedAtMs = 0L
testWantMenuOpen = false
}
)
}
}
LCDSubButton(
text = if (careJournalOpen) "CARE JOURNAL ▲" else "CARE JOURNAL ▼",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
careJournalOpen = !careJournalOpen
if (careJournalOpen) {
infoPanelOpen = false
testWantMenuOpen = false
testBondMenuOpen = false
}
}
)
if (careJournalOpen) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
LCDSubButton(
text = "NEEDS",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { careJournalTab = "Needs" },
modifier = Modifier.weight(1f),
fillWidthFraction = 1f
)
LCDSubButton(
text = "WANTS",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { careJournalTab = "Wants" },
modifier = Modifier.weight(1f),
fillWidthFraction = 1f
)
LCDSubButton(
text = "ALL",
enabled = true,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = { careJournalTab = "All" },
modifier = Modifier.weight(1f),
fillWidthFraction = 1f
)
}
val visibleJournalEntries = when (careJournalTab) {
"Needs" -> careJournalEntries.filter { it.category == "Need" }
"Wants" -> careJournalEntries.filter { it.category == "Want" }
else -> careJournalEntries
}
when (careJournalTab) {
"Needs" -> {
Row {
Text(
text = "Need",
fontWeight = FontWeight.Bold,
modifier = Modifier.width(150.dp)
)
Text(
text = "When",
fontWeight = FontWeight.Bold
)
}
}
"Wants" -> {
Row {
Text(
text = "Want",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1.4f),
maxLines = 1,
softWrap = false
)
Text(
text = "Resp.",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(0.8f),
maxLines = 1,
softWrap = false
)
Text(
text = "When",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(0.9f),
maxLines = 1,
softWrap = false
)
}
}
else -> {
Text(
text = "All entries",
fontWeight = FontWeight.Bold
)
}
}
if (visibleJournalEntries.isEmpty()) {
Text(
text = "No entries yet.",
color = lcdFaded,
fontWeight = FontWeight.Bold
)
} else {
visibleJournalEntries.take(8).forEach { entry ->
val resultMark = if (entry.met) "✓" else "✕"
val actionText = "$resultMark ${entry.action}"
when (careJournalTab) {
"Needs" -> {
Row {
Text(
text = actionText,
modifier = Modifier.width(150.dp),
maxLines = 1,
softWrap = false
)
Text(text = journalDateText(entry.happenedAtMs))
}
}
"Wants" -> {
Row {
Text(
text = actionText,
modifier = Modifier.weight(1.4f),
maxLines = 1,
softWrap = false
)
Text(
text = entry.responseMs?.let {
journalResponseText(it)
} ?: "—",
modifier = Modifier.weight(0.8f),
maxLines = 1,
softWrap = false
)
Text(
text = journalDateText(entry.happenedAtMs),
modifier = Modifier.weight(0.9f),
maxLines = 1,
softWrap = false
)
}
}
else -> {
val responsePart = if (
entry.category == "Want" &&
entry.responseMs != null
) {
" · ${journalResponseText(entry.responseMs)}"
} else {
""
}
Text(
text = "${entry.category} $actionText$responsePart · ${journalDateText(entry.happenedAtMs)}",
maxLines = 1,
softWrap = false
)
}
}
}
}
}
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = petFrameMinHeight)
.padding(top = petFrameOuterPadding, bottom = petFrameOuterPadding)
.background(lcdBackground)
.border(2.dp, petFrameForeground),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = petFrameInnerPadding, bottom = petFrameInnerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val downtimeActive =
testingMode && downtimeMode != DowntimeMode.None
val downtimeTitle = when {
testingMode && downtimeMode == DowntimeMode.TouchGrass -> "TOUCH GRASS"
testingMode && downtimeMode == DowntimeMode.WatchTv -> "WATCH TV"
else -> ""
}
if (downtimeTitle.isNotEmpty()) {
Text(
text = downtimeTitle,
fontWeight = FontWeight.Bold,
color = petFrameForeground
)
} else {
Text(
text = healthBar,
color = petFrameForeground
)
}
Spacer(
modifier = Modifier.height(
if (downtimeActive) {
(4f * layoutScale).dp
} else {
(10f * layoutScale).dp
}
)
)
if (testingMode && downtimeMode == DowntimeMode.TouchGrass) {
TouchGrassActivityScreen(
lcdDark = petFrameForeground,
onGrassTap = {}
)
} else if (testingMode && downtimeMode == DowntimeMode.WatchTv) {
WatchTvActivityScreen(
lcdDark = petFrameForeground,
selectedChannel = selectedTvChannel,
weatherLabel = weather,
onChannelSelected = { selectedTvChannel = it }
)
} else {
Text(
text = petArt,
fontSize = 24.sp,
lineHeight = 24.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
color =
if (petHomeColor == "K") {
petFrameForeground
} else if (displayStatus == "Recovering") {
sickGreen
} else {
petColor
}
)
}
if (!downtimeActive) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = framePropText,
modifier = Modifier.width(160.dp),
fontSize = 20.sp,
lineHeight = 20.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
color = petFrameForeground,
softWrap = false,
maxLines = 1
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when {
alertText.isNotEmpty() -> alertText
wantText.isNotEmpty() -> wantText
else -> "Status: $displayStatus"
},
fontWeight = if (
alertText.isNotEmpty() || wantText.isNotEmpty()
) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color = petFrameForeground,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(meterTopPadding))
Text(
text = "┈".repeat(28),
color = petFrameForeground,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
maxLines = 1,
softWrap = false
)
Spacer(modifier = Modifier.height(meterSpacing))
Column(
verticalArrangement = Arrangement.spacedBy(meterSpacing)
) {
val meterLabelWidth = 120.dp
val meterBarPadding = 8.dp
Row {
Text(
text = "Happiness",
modifier = Modifier.width(meterLabelWidth),
textAlign = TextAlign.End,
maxLines = 1,
softWrap = false
)
Text(
text = happinessBar,
modifier = Modifier.padding(start = meterBarPadding),
fontFamily = FontFamily.Monospace,
maxLines = 1,
softWrap = false
)
}
Row {
Text(
text = "Energy",
modifier = Modifier.width(meterLabelWidth),
textAlign = TextAlign.End,
maxLines = 1,
softWrap = false
)
Text(
text = energyBar,
modifier = Modifier.padding(start = meterBarPadding),
fontFamily = FontFamily.Monospace,
maxLines = 1,
softWrap = false
)
}
Row {
Text(
text = "Hunger",
modifier = Modifier.width(meterLabelWidth),
textAlign = TextAlign.End,
maxLines = 1,
softWrap = false
)
Text(
text = hungerBar,
modifier = Modifier.padding(start = meterBarPadding),
fontFamily = FontFamily.Monospace,
maxLines = 1,
softWrap = false
)
}
Row {
Text(
text = "Dirtiness",
modifier = Modifier.width(meterLabelWidth),
textAlign = TextAlign.End,
maxLines = 1,
softWrap = false
)
Text(
text = dirtinessBar,
modifier = Modifier.padding(start = meterBarPadding),
fontFamily = FontFamily.Monospace,
maxLines = 1,
softWrap = false
)
}
Row {
Text(
text = "Potty",
modifier = Modifier.width(meterLabelWidth),
textAlign = TextAlign.End,
maxLines = 1,
softWrap = false
)
Text(
text = pottyBar,
modifier = Modifier.padding(start = meterBarPadding),
fontFamily = FontFamily.Monospace,
maxLines = 1,
softWrap = false
)
}
Row {
Text(
text = "Boredom",
modifier = Modifier.width(meterLabelWidth),
textAlign = TextAlign.End,
maxLines = 1,
softWrap = false
)
Text(
text = boredomBar,
modifier = Modifier.padding(start = meterBarPadding),
fontFamily = FontFamily.Monospace,
maxLines = 1,
softWrap = false
)
}
}
}
if ((isSleeping && location == "Inside") || roughWakeDarkFlash) {
Box(
modifier = Modifier
.matchParentSize()
.background(Color.Gray.copy(alpha = 0.38f))
)
}
if (isFindingNewPet) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray.copy(alpha = 0.28f)),
contentAlignment = Alignment.Center
) {
Text(
text = "FINDING PET...",
fontWeight = FontWeight.Bold,
color = petFrameForeground
)
}
}
if (infoPanelOpen) {
androidx.compose.ui.window.Popup(
alignment = Alignment.TopCenter
) {
Box(
modifier = Modifier
.width(androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp.dp)
.height(androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp)
.background(petHomeBackground)
.border(2.dp, lcdDark)
.padding(10.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 44.dp, bottom = 54.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = when (infoPanelType) {
"HowTo" -> "HOW TO"
"Extras" -> "EXTRAS"
"Appearance" -> "APPEARANCE"
"Renovation" -> "HOME RENOVATION"
else -> "CREDITS / LICENSES"
},
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (infoPanelType == "HowTo") {
howToText.lines().forEach { howToLine ->
Text(
text = howToLine,
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace,
fontWeight = if (
howToLine.isNotBlank() &&
howToLine == howToLine.uppercase()
) {
FontWeight.Bold
} else {
FontWeight.Normal
}
)
}
} else if (infoPanelType == "Extras") {
Text(
text = "Credits / Licenses",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable {
infoPanelType = "Credits"
}
.padding(vertical = 10.dp)
)
Text(
text = "Appearance: $appearance",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable {
infoPanelType = "Appearance"
}
.padding(vertical = 10.dp)
)
} else if (infoPanelType == "Appearance") {
Text(
text = "Choose how LCDPet looks.",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "System follows the device setting. Light and Dark stay fixed until changed.",
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
listOf("System", "Light", "Dark").forEach { option ->
val selected = appearance == option
Text(
text =
if (selected) {
"[X] ${option.uppercase()}"
} else {
"[ ] ${option.uppercase()}"
},
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.border(
width = if (selected) 2.dp else 1.dp,
color = lcdDark
)
.clickable {
saveScope.launch {
context.setLCDPetAppearance(option)
}
}
.padding(
horizontal = 12.dp,
vertical = 10.dp
)
)
Spacer(modifier = Modifier.height(6.dp))
}
} else if (infoPanelType == "Renovation") {
Text(
text = "Choose a color for $displayPetName's home.",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Tap a color to preview and save it.",
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(petHomeBackground)
.border(2.dp, lcdDark)
.padding(12.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(8.dp)
) {
Text(
text = "BACKGROUND PREVIEW",
color = lcdDark,
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
Box(
modifier = Modifier
.fillMaxWidth(0.78f)
.background(lcdBackground)
.border(
2.dp,
petFrameForeground
)
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment =
Alignment.CenterHorizontally
) {
Text(
text = displayPetName,
color = petFrameForeground,
fontWeight = FontWeight.Bold
)
Text(
text = petArt,
color = petFrameForeground,
fontSize = 20.sp,
lineHeight = 20.sp,
fontFamily =
FontFamily.Monospace,
textAlign = TextAlign.Center
)
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
val renovationOptions = listOf(
Triple("LCD", "LCD", lcdBackground),
Triple("R", "RED", Color(0xFFD98C8C)),
Triple("G", "GREEN", Color(0xFF8FBF8F)),
Triple("B", "BLUE", Color(0xFF8FAFD6)),
Triple("Y", "YELLOW", Color(0xFFD8C66A)),
Triple("P", "PURPLE", Color(0xFF9B7BC1)),
Triple("K", "BLACK", Color.Black)
)
renovationOptions.chunked(2).forEach { rowOptions ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement =
Arrangement.spacedBy(6.dp)
) {
rowOptions.forEach { option ->
val code = option.first
val optionBackground = option.third
val selected = petHomeColor == code
val optionForeground =
if (code == "K") {
Color.White
} else {
lcdDark
}
Box(
modifier = Modifier
.weight(1f)
.background(optionBackground)
.border(
width =
if (selected) {
3.dp
} else {
1.dp
},
color = optionForeground
)
.clickable {
saveScope.launch {
context.setLCDPetHomeColor(
code
)
}
}
.padding(
horizontal = 4.dp,
vertical = 10.dp
),
contentAlignment =
Alignment.Center
) {
Text(
text =
if (selected) {
"[X] $code"
} else {
"[ ] $code"
},
color = optionForeground,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
if (rowOptions.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
Spacer(modifier = Modifier.height(6.dp))
}
} else {
creditsText.lines().forEach { creditLine ->
if (creditLine.startsWith("Sound page: ")) {
val creditUrl = creditLine.removePrefix("Sound page: ").trim()
Text(
text = "Sound page:",
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace
)
Text(
text = creditUrl,
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(creditUrl)
)
)
}
)
} else if (
creditLine.startsWith("https://") ||
creditLine.startsWith("http://")
) {
Text(
text = creditLine,
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(creditLine)
)
)
}
)
} else {
Text(
text = creditLine,
fontSize = 11.sp,
lineHeight = 13.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
val infoPanelControlsOnLeft = infoPanelButtonCorner in listOf("BottomLeft", "TopLeft")
Row(
modifier = Modifier.align(
when (infoPanelButtonCorner) {
"BottomLeft" -> Alignment.BottomStart
"TopLeft" -> Alignment.TopStart
"TopRight" -> Alignment.TopEnd
else -> Alignment.BottomEnd
}
),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (infoPanelControlsOnLeft) {
Text(
text = "CLOSE",
fontWeight = FontWeight.Bold,
modifier = Modifier
.background(petHomeBackground)
.border(1.dp, lcdDark)
.clickable {
infoPanelOpen = false
}
.padding(horizontal = 10.dp, vertical = 6.dp)
)
Text(
text = "MOVE",
fontWeight = FontWeight.Bold,
modifier = Modifier
.background(petHomeBackground)
.border(1.dp, lcdDark)
.clickable {
infoPanelButtonCorner = when (infoPanelButtonCorner) {
"BottomRight" -> "BottomLeft"
"BottomLeft" -> "TopLeft"
"TopLeft" -> "TopRight"
else -> "BottomRight"
}
}
.padding(horizontal = 10.dp, vertical = 6.dp)
)
} else {
Text(
text = "MOVE",
fontWeight = FontWeight.Bold,
modifier = Modifier
.background(petHomeBackground)
.border(1.dp, lcdDark)
.clickable {
infoPanelButtonCorner = when (infoPanelButtonCorner) {
"BottomRight" -> "BottomLeft"
"BottomLeft" -> "TopLeft"
"TopLeft" -> "TopRight"
else -> "BottomRight"
}
}
.padding(horizontal = 10.dp, vertical = 6.dp)
)
Text(
text = "CLOSE",
fontWeight = FontWeight.Bold,
modifier = Modifier
.background(petHomeBackground)
.border(1.dp, lcdDark)
.clickable {
infoPanelOpen = false
}
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(controlsTopSpacing))
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(actionSpacing)
) {
val actionInProgress =
(testingMode && downtimeMode != DowntimeMode.None) ||
isTradingInPet ||
isFindingNewPet ||
isSleeping ||
isBathing ||
isFeeding ||
framePropAnimation.isNotEmpty() ||
(frameProp.isNotEmpty() && !isTalking)
val canAct = !actionInProgress
val canStartAction = canAct && !isTalking
val canBathe = canStartAction && location == "Inside" && (dirtiness > 0 || currentWant == "Bath")
val canFeed =
canStartAction &&
location == "Inside" &&
(currentWant == "Snack" || hunger >= 3) &&
!(status in listOf(
"Sick",
"Weak",
"Recovering",
"RecoverWeak"
) && weakCause == "Overfed")
val canPlay = canStartAction && location == "Inside" && energy > 0 && status !in listOf(
"Weak",
"RecoverWeak"
)
val canPet = canStartAction && location == "Inside"
val canTalk = canAct && location == "Inside"
val canNap = canStartAction && location == "Inside" && energy < 10
val canPotty = canStartAction && location == "Outside" && potty > 0
val canWalk = canStartAction && location == "Outside" && weather != "Snow" && energy > 0 && status !in listOf(
"Sick",
"Recovering",
"Weak",
"RecoverWeak"
)
if (location == "Inside") {
LCDMenuButton(
text = if (careMenuOpen) "CARE ▲" else "CARE ▼",
enabled = canStartAction,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
careMenuOpen = !careMenuOpen
if (careMenuOpen) {
socializeMenuOpen = false
if (!testingMode) {
statsMenuOpen = false
}
}
}
)
if (careMenuOpen) {
LCDSubMenuFrame(lcdDark = lcdDark) {
LCDSubButton(
text = "BATHE",
modifier = Modifier.weight(1f),
fillWidthFraction = 1f,
enabled = canBathe,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
bathePet()
}
)
LCDSubButton(
text = "FEED",
modifier = Modifier.weight(1f),
fillWidthFraction = 1f,
enabled = canFeed,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
feedPet()
}
)
LCDSubButton(
text = "NAP",
modifier = Modifier.weight(1f),
fillWidthFraction = 1f,
enabled = canNap,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
napPet()
}
)
}
}
LCDMenuButton(
text = if (socializeMenuOpen) "SOCIALIZE ▲" else "SOCIALIZE ▼",
enabled = canStartAction,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
socializeMenuOpen = !socializeMenuOpen
if (socializeMenuOpen) {
careMenuOpen = false
if (!testingMode) {
statsMenuOpen = false
}
}
}
)
if (socializeMenuOpen) {
LCDSubMenuFrame(lcdDark = lcdDark) {
LCDSubButton(
text = "PET",
modifier = Modifier.weight(1f),
fillWidthFraction = 1f,
enabled = canPet,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
petPet()
}
)
LCDSubButton(
text = "PLAY",
modifier = Modifier.weight(1f),
fillWidthFraction = 1f,
enabled = canPlay,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
playPet()
}
)
LCDHoldSubButton(
text = "TALK",
modifier = Modifier.weight(1f),
fillWidthFraction = 1f,
enabled = canTalk,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onPress = {
if (microphonePermissionGranted) {
beginTalk()
} else {
microphonePermissionLauncher.launch(
Manifest.permission.RECORD_AUDIO
)
}
},
onRelease = { _ ->
endTalk()
}
)
}
}
} else {
LCDMenuButton(
text = "WALK",
enabled = canWalk,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
walkPet()
}
)
LCDMenuButton(
text = "POTTY",
enabled = canPotty,
lcdDark = lcdDark,
lcdFaded = lcdFaded,
onClick = {
usePotty()
}
)
}
if (!careMenuOpen && !socializeMenuOpen) {
Text(
text = "How To",
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = howToTopPadding, bottom = 4.dp)
.clickable {
infoPanelType = "HowTo"
infoPanelOpen = true
statsMenuOpen = false
careJournalOpen = false
testWantMenuOpen = false
testBondMenuOpen = false
careMenuOpen = false
socializeMenuOpen = false
}
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
LCDPetTheme {
Greeting("LCDPet")
}
}