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(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>(emptyList()) } var pottyFaceOverride by remember { mutableStateOf("") } var pottyFaceBag by remember { mutableStateOf(emptyList()) } 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): 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 { 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 = "You’ll 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") } }