diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1e45b6..50199d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "today.notfeelingit.lcdpet" minSdk = 26 targetSdk = 36 - versionCode = 2 - versionName = "1.1" + versionCode = 3 + versionName = "1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -47,6 +47,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation("androidx.datastore:datastore-preferences:1.2.1") + implementation("androidx.work:work-runtime-ktx:2.11.2") testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bd18db4..5b9e6aa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + grassWidthPx = coordinates.size.width } - .pointerInput(Unit) { + .pointerInput(grassColumns) { detectDragGestures( onDragStart = { offset -> updateGrassFinger(offset.x) @@ -128,19 +147,59 @@ fun WatchTvActivityScreen( // 1 = on, 2 = CRT shutting off, 0 = off val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var appInForeground by remember { + mutableStateOf( + lifecycleOwner.lifecycle.currentState.isAtLeast( + Lifecycle.State.RESUMED + ) + ) + } + var weatherChannelPlayer by remember { + mutableStateOf(null) + } + var staticChannelPlayer by remember { + mutableStateOf(null) + } + + val tvTextFontSize = with(LocalDensity.current) { 14.dp.toSp() } + val tvTextLineHeight = with(LocalDensity.current) { 18.dp.toSp() } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + appInForeground = true + } + + Lifecycle.Event.ON_PAUSE, + Lifecycle.Event.ON_STOP -> { + appInForeground = false + } + + else -> Unit + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } LaunchedEffect(selectedChannel, tvPowerState) { tvFrame = 0 while (true) { - delay(if (tvPowerState == 2) 120L else 450L) + delay(if (tvPowerState == 2) 145L else 450L) tvFrame += 1 } } LaunchedEffect(tvPowerState) { if (tvPowerState == 2) { - delay(420L) + delay(580L) tvPowerState = 0 } } @@ -158,7 +217,12 @@ fun WatchTvActivityScreen( start() } - while (powerOffPlayer?.isPlaying == true) { + while ( + powerOffPlayer?.isPlaying == true && + lifecycleOwner.lifecycle.currentState.isAtLeast( + Lifecycle.State.RESUMED + ) + ) { delay(50L) } } finally { @@ -167,34 +231,91 @@ fun WatchTvActivityScreen( } } - DisposableEffect(selectedChannel, tvPowerState) { - var player: MediaPlayer? = null - - if (tvPowerState == 1) { - val soundResource = when (selectedChannel) { - TvChannel.Weather -> R.raw.weather_channel_jazz - TvChannel.NoSignal -> R.raw.tv_static_light + DisposableEffect(tvPowerState) { + val weatherPlayer = + if (tvPowerState == 1) { + MediaPlayer.create( + context, + R.raw.weather_channel_jazz + )?.apply { + isLooping = true + setVolume(0f, 0f) + start() + } + } else { + null } - val channelVolume = when (selectedChannel) { - TvChannel.Weather -> 0.75f - TvChannel.NoSignal -> 0.60f + val staticPlayer = + if (tvPowerState == 1) { + MediaPlayer.create( + context, + R.raw.tv_static_light + )?.apply { + isLooping = true + setVolume(0f, 0f) + start() + } + } else { + null } - player = MediaPlayer.create(context, soundResource)?.apply { - isLooping = true - setVolume(channelVolume, channelVolume) - start() - } - } + weatherChannelPlayer = weatherPlayer + staticChannelPlayer = staticPlayer onDispose { - player?.stop() - player?.release() - player = null + if (weatherChannelPlayer === weatherPlayer) { + weatherChannelPlayer = null + } + if (staticChannelPlayer === staticPlayer) { + staticChannelPlayer = null + } + + listOf(weatherPlayer, staticPlayer).forEach { player -> + runCatching { + if (player?.isPlaying == true) { + player.stop() + } + } + player?.release() + } } } + LaunchedEffect( + appInForeground, + tvPowerState, + selectedChannel, + weatherChannelPlayer, + staticChannelPlayer + ) { + val canHearTv = + appInForeground && tvPowerState == 1 + + val weatherVolume = + if (canHearTv && selectedChannel == TvChannel.Weather) { + 0.75f + } else { + 0f + } + + val staticVolume = + if (canHearTv && selectedChannel == TvChannel.NoSignal) { + 0.60f + } else { + 0f + } + + weatherChannelPlayer?.setVolume( + weatherVolume, + weatherVolume + ) + staticChannelPlayer?.setVolume( + staticVolume, + staticVolume + ) + } + Column( modifier = Modifier .fillMaxWidth() @@ -267,25 +388,73 @@ fun WatchTvActivityScreen( .padding(horizontal = 10.dp, vertical = 8.dp), contentAlignment = Alignment.Center ) { - Text( - text = if (tvPowerState == 1) { - tvPreviewText( - channel = selectedChannel, - frame = tvFrame, - weatherLabel = weatherLabel + if ( + tvPowerState == 1 && + selectedChannel == TvChannel.Weather + ) { + val weatherLines = weatherPreviewText(weatherLabel).lines() + val weatherGraphic = weatherLines.joinToString("\n") { + it.take(11) + } + val weatherDetails = weatherLines.joinToString("\n") { + it.drop(15) + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = weatherGraphic, + fontFamily = FontFamily.Monospace, + fontSize = tvTextFontSize, + lineHeight = tvTextLineHeight, + color = lcdDark, + modifier = Modifier.width(92.dp), + softWrap = false, + maxLines = 3 ) - } else { - tvPowerOffPreviewText( - frame = tvFrame, - shuttingDown = tvPowerState == 2 + + Spacer(modifier = Modifier.width(34.dp)) + + Text( + text = weatherDetails, + fontFamily = FontFamily.Monospace, + fontSize = tvTextFontSize, + lineHeight = tvTextLineHeight, + color = lcdDark, + modifier = Modifier.offset(x = (-10).dp), + softWrap = false, + maxLines = 3 ) - }, - fontFamily = FontFamily.Monospace, - textAlign = if (tvPowerState == 1) TextAlign.Start else TextAlign.Center, - color = if (tvPowerState == 1) lcdDark else Color.White, - modifier = Modifier.fillMaxWidth(), - softWrap = !(tvPowerState == 1 && selectedChannel == TvChannel.NoSignal) - ) + } + } else { + Text( + text = if (tvPowerState == 1) { + tvPreviewText( + channel = selectedChannel, + frame = tvFrame, + weatherLabel = weatherLabel + ) + } else { + tvPowerOffPreviewText( + frame = tvFrame, + shuttingDown = tvPowerState == 2 + ) + }, + fontFamily = FontFamily.Monospace, + fontSize = tvTextFontSize, + lineHeight = tvTextLineHeight, + textAlign = if (tvPowerState == 1) { + TextAlign.Start + } else { + TextAlign.Center + }, + color = if (tvPowerState == 1) lcdDark else Color.White, + modifier = Modifier.fillMaxWidth(), + softWrap = false, + maxLines = 5 + ) + } } } @@ -418,12 +587,19 @@ private fun weatherPreviewText(weatherLabel: String): String { val normalizedWeather = weatherLabel.lowercase() val conditionText = when { + normalizedWeather.contains("snow") -> "SNOWING" normalizedWeather.contains("rain") -> "RAINING" normalizedWeather.contains("cloud") || normalizedWeather.contains("overcast") -> "CLOUDY" else -> "SUNNY" } val iconLines = when (conditionText) { + "SNOWING" -> listOf( + " .--. ", + " (____) ", + " * * * " + ) + "RAINING" -> listOf( " .--. ", " (____) ", diff --git a/app/src/main/java/today/notfeelingit/lcdpet/LCDPetNotificationWorker.kt b/app/src/main/java/today/notfeelingit/lcdpet/LCDPetNotificationWorker.kt new file mode 100644 index 0000000..ea04460 --- /dev/null +++ b/app/src/main/java/today/notfeelingit/lcdpet/LCDPetNotificationWorker.kt @@ -0,0 +1,266 @@ +package today.notfeelingit.lcdpet + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import java.util.concurrent.TimeUnit + +private const val LCDPET_WORKER_CHANNEL_ID = "lcdpet_alerts" +private const val LCDPET_WORKER_URGENT_CHANNEL_ID = "lcdpet_urgent_alerts_v2" + +private const val INPUT_WORK_TYPE = "work_type" +private const val INPUT_NOTIFICATION_ID = "notification_id" +private const val INPUT_NOTIFICATION_TITLE = "notification_title" +private const val INPUT_NOTIFICATION_MESSAGE = "notification_message" +private const val INPUT_URGENT_NEED = "urgent_need" + +private const val WORK_TYPE_NOTIFICATION = "notification" +private const val WORK_TYPE_WANT = "want" + +private const val LCDPET_BACKGROUND_TEST_WORK = "lcdpet_background_test" + +class LCDPetNotificationWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + val workType = + inputData.getString(INPUT_WORK_TYPE) ?: WORK_TYPE_NOTIFICATION + + if (workType == WORK_TYPE_WANT) { + val createdWant = + applicationContext.createLCDPetWantIfDue() + ?: return Result.success() + + sendLCDPetWorkerNotification( + context = applicationContext, + notificationId = 2001, + title = "LCDPet", + message = wantNotificationText(createdWant) + ) + + return Result.success() + } + + val notificationId = inputData.getInt(INPUT_NOTIFICATION_ID, -1) + val title = inputData.getString(INPUT_NOTIFICATION_TITLE) ?: "LCDPet" + val message = inputData.getString(INPUT_NOTIFICATION_MESSAGE).orEmpty() + val urgentNeed = inputData.getBoolean(INPUT_URGENT_NEED, false) + + if (notificationId < 0 || message.isBlank()) { + return Result.failure() + } + + sendLCDPetWorkerNotification( + context = applicationContext, + notificationId = notificationId, + title = title, + message = message, + urgentNeed = urgentNeed + ) + + return Result.success() + } +} + +fun scheduleLCDPetNotificationWork( + context: Context, + uniqueWorkName: String, + delayMs: Long, + notificationId: Int, + message: String, + title: String = "LCDPet", + urgentNeed: Boolean = false +) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs.coerceAtLeast(0L), TimeUnit.MILLISECONDS) + .setInputData( + workDataOf( + INPUT_WORK_TYPE to WORK_TYPE_NOTIFICATION, + INPUT_NOTIFICATION_ID to notificationId, + INPUT_NOTIFICATION_TITLE to title, + INPUT_NOTIFICATION_MESSAGE to message, + INPUT_URGENT_NEED to urgentNeed + ) + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.REPLACE, + request + ) +} + +fun scheduleLCDPetWantWork( + context: Context, + uniqueWorkName: String, + delayMs: Long +) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay( + delayMs.coerceAtLeast(0L), + TimeUnit.MILLISECONDS + ) + .setInputData( + workDataOf( + INPUT_WORK_TYPE to WORK_TYPE_WANT + ) + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.REPLACE, + request + ) +} + +fun cancelLCDPetNotificationWork( + context: Context, + uniqueWorkName: String +) { + WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName) +} + +fun scheduleLCDPetBackgroundTest(context: Context) { + scheduleLCDPetNotificationWork( + context = context, + uniqueWorkName = LCDPET_BACKGROUND_TEST_WORK, + delayMs = 30_000L, + notificationId = 1099, + message = "Background worker test: beep boop." + ) +} + +private fun wantNotificationText( + createdWant: LCDPetCreatedWant +): String { + val notificationPetName = + createdWant.petName.ifBlank { "Your pet" } + + return when (createdWant.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." + } +} + +private fun sendLCDPetWorkerNotification( + context: Context, + notificationId: Int, + title: String, + message: String, + urgentNeed: Boolean = false +) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (urgentNeed) { + val urgentSoundUri = + Uri.parse( + "android.resource://${context.packageName}/${R.raw.need_urgent}" + ) + + val urgentAudioAttributes = + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + + val urgentChannel = NotificationChannel( + LCDPET_WORKER_URGENT_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)) + } + + manager.createNotificationChannel(urgentChannel) + } else { + val channel = NotificationChannel( + LCDPET_WORKER_CHANNEL_ID, + "LCDPet Alerts", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Pet care reminders and LCDPet status alerts." + } + + manager.createNotificationChannel(channel) + } + } + + 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_WORKER_URGENT_CHANNEL_ID + } else { + LCDPET_WORKER_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()) +} diff --git a/app/src/main/java/today/notfeelingit/lcdpet/MainActivity.kt b/app/src/main/java/today/notfeelingit/lcdpet/MainActivity.kt index c4bc646..11d3445 100644 --- a/app/src/main/java/today/notfeelingit/lcdpet/MainActivity.kt +++ b/app/src/main/java/today/notfeelingit/lcdpet/MainActivity.kt @@ -10,8 +10,10 @@ 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 @@ -21,6 +23,7 @@ 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 @@ -37,17 +40,20 @@ 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 @@ -68,10 +74,108 @@ 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, @@ -105,7 +209,7 @@ private val lcdPetTemplates = listOf( topLine = " /^^^\\ \n", facePrefix = "( ", faceSuffix = " )\n", - feetLine = " /|_|\\ " + feetLine = " /| |\\ " ), PetTemplate( id = "bean", @@ -157,8 +261,41 @@ class MainActivity : ComponentActivity() { createLCDPetNotificationChannel() setContent { - LCDPetTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + 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) @@ -178,8 +315,33 @@ class MainActivity : ComponentActivity() { description = "Pet care reminders and LCDPet status alerts." } - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + 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) } } } @@ -256,7 +418,14 @@ fun LCDMenuButton( Text( text = text, fontWeight = FontWeight.Bold, - color = if (enabled) Color.White else lcdDark, + color = + if (enabled && lcdDark == Color.White) { + Color.Black + } else if (enabled) { + Color.White + } else { + lcdDark + }, textAlign = TextAlign.Center ) } @@ -298,7 +467,7 @@ fun LCDHoldSubButton( lcdDark: Color, lcdFaded: Color, onPress: () -> Unit, - onRelease: () -> Unit, + onRelease: (releasedNormally: Boolean) -> Unit, modifier: Modifier = Modifier, fillWidthFraction: Float = 0.86f ) { @@ -311,10 +480,12 @@ fun LCDHoldSubButton( onPress = { if (enabled) { onPress() + var releasedNormally = false + try { - tryAwaitRelease() + releasedNormally = tryAwaitRelease() } finally { - onRelease() + onRelease(releasedNormally) } } } @@ -352,14 +523,36 @@ fun LCDSubMenuFrame( @Composable fun Greeting(name: String, modifier: Modifier = Modifier) { val lcdBackground = Color(0xFFC4C7B0) - val lcdDark = Color(0xFF343434) - + val lcdFrameDark = Color(0xFF343434) val sickGreen = Color(0xFF2F7D32) - val lcdFaded = lcdDark.copy(alpha = 0.30f) 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 = {} @@ -448,8 +641,10 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) } @@ -457,9 +652,12 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) } @@ -477,6 +675,10 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) } @@ -491,6 +693,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) } @@ -510,6 +713,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) { @@ -544,6 +748,12 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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 { @@ -558,38 +768,25 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } - fun sendLCDPetNotification(notificationId: Int, title: String, message: String) { - 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 + 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 ) - - val notification = NotificationCompat.Builder(context, LCDPET_NOTIFICATION_CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle(title) - .setContentText(message) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .build() - - NotificationManagerCompat.from(context).notify(notificationId, notification) } fun wantNotificationText(wantName: String): String { @@ -618,7 +815,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { sendLCDPetNotification( notificationId = notificationId, title = "LCDPet", - message = message + message = message, + urgentNeed = true ) } @@ -626,7 +824,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { sendLCDPetNotification( notificationId = 1001, title = "LCDPet", - message = "Test alert: your pet says beep boop." + message = "Test alert: your pet says beep boop.", + allowInForeground = true ) } @@ -674,6 +873,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { bond = save.bond petAgeDays = save.petAgeDays + nextAgeAtMs = save.nextAgeAtMs careScore = save.careScore neglectScore = save.neglectScore hunger = save.hunger @@ -683,6 +883,18 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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 @@ -694,59 +906,238 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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 elapsedAwayMs = (nowMs - save.lastSavedAtMs).coerceAtLeast(0L) + val firstOutsideEnergyTickAtMs = + if (save.outsideEnergyNextTickAtMs > 0L) { + save.outsideEnergyNextTickAtMs + } else { + save.lastSavedAtMs + offlineOutsideEnergyDrainTickMs + } if ( !save.isSleeping && save.location == "Outside" && !save.isTradingInPet && - !save.isFindingNewPet && - elapsedAwayMs >= offlineOutsideEnergyDrainTickMs + !save.isFindingNewPet ) { - val energyLostAway = (elapsedAwayMs / offlineOutsideEnergyDrainTickMs).toInt() + when { + save.energy <= 0 -> { + energy = 0 + isSleeping = true + sleepMode = "Auto" + autoSleepStartedAtMs = save.lastSavedAtMs + outsideEnergyNextTickAtMs = 0L + restoredAutoSleep = true + } - if (energyLostAway >= save.energy) { - val timeUntilSleepMs = save.energy.toLong() * offlineOutsideEnergyDrainTickMs - energy = 0 - isSleeping = true - sleepMode = "Auto" - autoSleepStartedAtMs = save.lastSavedAtMs + timeUntilSleepMs - restoredAutoSleep = true - } else { - energy = (save.energy - energyLostAway).coerceAtLeast(0) - restoredAutoSleep = false + 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 { - restoredAutoSleep = save.isSleeping && save.sleepMode == "Auto" + 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 } } - val hungerBar = "▓".repeat(hunger) + "▒".repeat(10 - hunger) + 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 = "▓".repeat(happiness) + "▒".repeat(10 - happiness) - val energyBar = "▓".repeat(energy) + "▒".repeat(10 - energy) + val happinessBar = lcdMeterBar(happiness) + val energyBar = lcdMeterBar(energy) val bondBlocks = bond / 10 - val bondBar = "▓".repeat(bondBlocks) + "▒".repeat(10 - bondBlocks) + val bondBar = lcdMeterBar(bondBlocks) val bondPercent = bond - val dirtinessBar = "▓".repeat(dirtiness) + "▒".repeat(10 - dirtiness) - val pottyBar = "▓".repeat(potty) + "▒".repeat(10 - potty) - val boredomBar = "▓".repeat(boredom) + "▒".repeat(10 - boredom) + 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 { @@ -878,8 +1269,10 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } val recoveryNeeded = (3 + sickCount - if (happiness >= 8) 1 else 0).coerceAtLeast(1) - val isOutsideGoodWeather = location == "Outside" && weather != "Rain" + 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" @@ -895,6 +1288,9 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { // 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 @@ -935,7 +1331,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { append(selectedPetTemplate.feetLine) } - val petColor = if (status == "Sick") sickGreen else lcdDark + val petColor = + if (status == "Sick") sickGreen else petFrameForeground val alertText = when { // Locked event moments. @@ -976,6 +1373,11 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { // 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 @@ -1003,7 +1405,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { withStyle( SpanStyle( - color = if (isDark) lcdDark else lcdFaded, + color = + if (isDark) petFrameForeground else petFrameFaded, fontWeight = FontWeight.Bold ) ) { @@ -1018,7 +1421,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { withStyle( SpanStyle( - color = if (isActive) lcdDark else lcdFaded, + color = + if (isActive) petFrameForeground else petFrameFaded, fontWeight = if (isActive) { FontWeight.Bold } else { @@ -1040,8 +1444,9 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { fun nextWeather(currentWeather: String): String { return when (currentWeather) { "Sunny" -> "Cloudy" - "Cloudy" -> listOf("Sunny", "Rain").random() + "Cloudy" -> listOf("Sunny", "Rain", "Snow").random() "Rain" -> "Cloudy" + "Snow" -> "Cloudy" else -> "Sunny" } } @@ -1059,6 +1464,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { if (!force) { bondReady = false + bondReadyAtMs = + System.currentTimeMillis() + bondCooldownMs } } } @@ -1080,6 +1487,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { if (outsideWantMatched) { currentWant = "" + currentWantStartedAtMs = 0L happyOutsideTrip = true } @@ -1105,7 +1513,6 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } else { location = "Inside" - weather = "Sunny" happyOutsideTrip = false if (status !in listOf("Sick", "Recovering", "Weak", "RecoverWeak")) { @@ -1114,11 +1521,32 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } + 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" } @@ -1224,13 +1652,17 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } fun feedPet() { + if (isFeeding) { + return + } + framePropAnimation = "" framePropStyle = 0 frameProp = "[___]" if (completeWant("Snack")) { - // Wanted snack is a tiny treat, not a full meal. - // It should not overfeed, fill potty, or create a new care problem. + 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 @@ -1242,53 +1674,39 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { return } - if (hunger <= 0) { - if (!overfeedNeedWindowOpen) { - logNeedJournal("Overfed", met = false) - overfeedNeedWindowOpen = true - } - - overfeedCount += 1 - - if (overfeedCount == 3) { - neglectScore = (neglectScore + 1).coerceAtMost(999) - weakCause = "Overfed" - - if (damageHealth() > 0) { - status = "Sick" - } - } - } else { - hunger -= 1 - logNeedJournal("Feed", met = true) - feedPottyCount += 1 - - if (feedPottyCount >= 3) { - potty = (potty + 1).coerceAtMost(10) - feedPottyCount = 0 - } - - starvingCount = 0 - hungerNeedWindowOpen = false - overfeedNeedWindowOpen = false - careScore = (careScore + 1).coerceAtMost(999) - buildBond() - goodFeedCount += 1 - - if (goodFeedCount >= 2) { - happiness = (happiness + 1).coerceAtMost(10) - goodFeedCount = 0 - } - - if (status == "Sick") { - status = "Recovering" - recoveryCount = 0 - } + 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) { @@ -1338,6 +1756,33 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } + 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 ( @@ -1349,6 +1794,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { framePropAnimation.isEmpty() ) { isTalking = true + talkHeardSound = false activityMood = if (bond >= 100) "Happy" else "Calm" framePropAnimation = "" framePropStyle = 0 @@ -1375,6 +1821,11 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) { recorder?.release() + isTalking = false + microphoneRecorder = null + microphoneLevel = 0 + talkHeardSound = false + frameProp = "" return } @@ -1415,6 +1866,10 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 1 -> "¢==-)" else -> "¢==-" } + + if (level > 0) { + registerHeardTalk() + } } } } @@ -1425,7 +1880,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } fun endTalk() { - if (isTalking) { + if (isTalking || microphoneRecorder != null) { isTalking = false microphoneRecorder?.let { recorder -> @@ -1439,28 +1894,14 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { microphoneRecorder = null microphoneLevel = 0 + talkHeardSound = false frameProp = "" - - 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() - } } } fun walkPet() { if (energy > 0) { + playPetSound(R.raw.pet_excited) activityMood = "Excited" startFrameAnimation("Footprints") careScore = (careScore + 1).coerceAtMost(999) @@ -1473,7 +1914,12 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { fun napPet() { roughWakeAlert = false - autoSleepStartedAtMs = 0L + + // 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" @@ -1486,6 +1932,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { choosePottyFace() startFrameAnimation("PottyClean") careScore = (careScore + 1).coerceAtMost(999) + pottyNextTickAtMs = 0L potty = 0 hunger = (hunger + 1).coerceAtMost(10) @@ -1518,6 +1965,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { fun resetPetAfterTradeIn() { location = "Inside" weather = newPetWeatherOptions.random() + nextWeatherAtMs = 0L status = "Nervous" hunger = 5 health = 10 @@ -1554,6 +2002,23 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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 @@ -1576,9 +2041,14 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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 @@ -1591,6 +2061,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { careMenuOpen = false socializeMenuOpen = false currentWant = "" + currentWantStartedAtMs = 0L frameProp = "" framePropAnimation = "" framePropStyle = 0 @@ -1619,6 +2090,15 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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, @@ -1630,12 +2110,20 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) ) } @@ -1663,6 +2151,15 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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, @@ -1674,12 +2171,20 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) ) } @@ -1693,6 +2198,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { location = "Inside" weather = "Sunny" + nextWeatherAtMs = 0L status = if (bond < 30) "Nervous" else "Calm" hunger = 5 health = 10 @@ -1732,6 +2238,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { roughWakeDarkFlash = false roughWakeNervousHold = false isBathing = false + isFeeding = false + feedingStartHunger = 0 isTalking = false bathBondAtStart = 0 bathWantMatched = false @@ -1739,8 +2247,46 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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") { @@ -1753,7 +2299,6 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { if (sleptOutside) { location = "Inside" - weather = "Sunny" bond = (bond - 1).coerceAtLeast(0) } @@ -1789,24 +2334,233 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } - DisposableEffect(lifecycleOwner, isSleeping, sleepMode, autoSleepWakeAllowed, restoredAutoSleep) { + 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, - Lifecycle.Event.ON_STOP -> { - if (isSleeping && sleepMode == "Auto") { + 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" && - (autoSleepWakeAllowed || restoredAutoSleep) + (currentAutoSleepWakeAllowed || currentRestoredAutoSleep) ) { restoredAutoSleep = true } + + if (currentSuppressBackgroundWant) { + nextWantAtMs = 0L + saveScope.launch { + context.setLCDPetNextWantAtMs(0L) + } + suppressBackgroundWantOnNextStop = false + } } else -> Unit @@ -1816,6 +2570,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) + endTalk() } } @@ -1836,6 +2591,58 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } + 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, @@ -1848,6 +2655,15 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { dirtiness, potty, boredom, + starvingCount, + recoveryCount, + sickCount, + playEnergyCount, + sameActionStreak, + boredomTriggerAt, + boredomPenaltyCount, + ultraTrollTriggered, + lastActionName, location, weather, status, @@ -1858,11 +2674,19 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { isFindingNewPet, tradeInProgress, currentWant, + currentWantStartedAtMs, + nextWantAtMs, petName, isSleeping, sleepMode, roughWakeAlert, autoSleepStartedAtMs, + nextWeatherAtMs, + bondReadyAtMs, + careCycleNextTickAtMs, + rainHappinessNextTickAtMs, + pottyNextTickAtMs, + dirtyHealthNextTickAtMs, careJournalEntries, saveLoaded ) { @@ -1879,6 +2703,15 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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, @@ -1890,12 +2723,20 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) ) } @@ -1954,6 +2795,15 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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, @@ -1965,12 +2815,20 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) ) } @@ -2005,46 +2863,181 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } - LaunchedEffect(currentWant, location, isSleeping, isBathing, isTradingInPet, isFindingNewPet, needsPetName) { - if (isTradingInPet || isFindingNewPet || needsPetName) { + 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) { - delay(wantCooldownOptionsMs.random()) - - if (currentWant.isEmpty() && !isSleeping && !isBathing) { - val possibleWants = if (location == "Inside") { - listOf("Snack", "Play", "Pet", "Talk", "Outside") + if (bond >= 100) listOf("Bath") else emptyList() + if ( + currentWant.isEmpty() && + !isSleeping && + !isBathing && + !isFeeding && + !suppressBackgroundWantOnNextStop + ) { + val nowMs = System.currentTimeMillis() + val dueAtMs = + if (nextWantAtMs > 0L) { + nextWantAtMs } else { - listOf("Play", "Pet", "Talk") + (nowMs + wantCooldownOptionsMs.random()).also { + nextWantAtMs = it + saveScope.launch { + context.setLCDPetNextWantAtMs(it) + } + } } - currentWant = possibleWants.random() - currentWantStartedAtMs = System.currentTimeMillis() - sendWantNotification(currentWant) + 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()) { - delay(wantDurationMs) + if (nextWantAtMs != 0L) { + nextWantAtMs = 0L + saveScope.launch { + context.setLCDPetNextWantAtMs(0L) + } + } - if (currentWant.isNotEmpty()) { - logWantJournal(currentWant, met = false) + 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) { - if (!isTradingInPet && !isFindingNewPet && !needsPetName) { - delay(weatherShiftOptionsMs.random()) + 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) { + 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) { - delay(bondCooldownMs) + bondReadyAtMs = 0L bondReady = true } } @@ -2056,19 +3049,22 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { return@LaunchedEffect } - if ( - hunger >= 10 && - starvingCount >= 2 && - status !in listOf("Sick", "Weak", "Recovering", "RecoverWeak") && - !hungerNeedNotificationOpen - ) { + 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." ) - } else if (hunger < 10 || status in listOf("Sick", "Weak", "Recovering", "RecoverWeak")) { - hungerNeedNotificationOpen = false } } @@ -2130,15 +3126,73 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } - LaunchedEffect(hunger, starvingCount, status, recoveryCount, sickCount, happiness, isSleeping, isTradingInPet, isFindingNewPet, needsPetName) { - if (isSleeping || isTradingInPet || isFindingNewPet || needsPetName) { + 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 } - if (status == "Sick") { - delay(sickTickMs) + 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 } @@ -2148,15 +3202,11 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { recoveryCount = 0 } } else if (status == "Weak") { - delay(weakRecoveryCheckMs) - if (location == "Inside" && energy > 0 && hunger < 10) { status = "RecoverWeak" health = 1 } } else if (status == "RecoverWeak") { - delay(recoverWeakMs) - if (location == "Inside" && hunger < 10) { status = "Calm" recoveryCount = 0 @@ -2166,8 +3216,6 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { weakCause = "" } } else if (status == "Recovering") { - delay(recoveringTickMs) - recoveryCount += 1 if (recoveryCount >= recoveryNeeded && hunger < 10) { @@ -2179,8 +3227,6 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { weakCause = "" } } else { - delay(hungerTickMs) - if (hunger < 10) { hunger += 1 overfeedCount = 0 @@ -2202,27 +3248,117 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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, isTradingInPet, isFindingNewPet, needsPetName) { - if (isTradingInPet || isFindingNewPet || needsPetName) { + 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 } - if (location == "Outside" && weather == "Rain" && happiness > 0 && !happyOutsideTrip) { - delay(rainHappinessTickMs) + 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) { - if (isSleeping || isTradingInPet || isFindingNewPet || needsPetName) { + + LaunchedEffect( + location, + potty, + isSleeping, + isTradingInPet, + isFindingNewPet, + needsPetName, + saveLoaded + ) { + if ( + !saveLoaded || + isSleeping || + isTradingInPet || + isFindingNewPet || + needsPetName + ) { + if (pottyNextTickAtMs != 0L) { + pottyNextTickAtMs = 0L + } return@LaunchedEffect } - delay(pottyTickMs) + 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) @@ -2242,29 +3378,138 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } - LaunchedEffect(dirtiness, status, isSleeping, isTradingInPet, isFindingNewPet, needsPetName) { - if (isSleeping || isTradingInPet || isFindingNewPet || needsPetName) { + 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 } - if (dirtiness >= 10 && status !in listOf("Sick", "Weak", "Recovering", "RecoverWeak")) { - delay(dirtyHealthTickMs) + val nowMs = System.currentTimeMillis() + val dueAtMs = + if (dirtyHealthNextTickAtMs > 0L) { + dirtyHealthNextTickAtMs + } else { + nowMs + dirtyHealthTickMs + } - if (dirtiness >= 10 && status !in listOf("Sick", "Weak", "Recovering", "RecoverWeak")) { - logNeedJournal("Bath", met = false) - neglectScore = (neglectScore + 1).coerceAtMost(999) - weakCause = "Dirty" - val newHealth = damageHealth() + if (dirtyHealthNextTickAtMs != dueAtMs) { + dirtyHealthNextTickAtMs = dueAtMs + } - if (newHealth > 0) { - status = "Sick" - } + 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(activityMood, isBathing) { - if (activityMood.isNotEmpty() && !isBathing) { + 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 = "" } @@ -2497,10 +3742,12 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } if (dirtiness < dirtinessBeforeBath) { + dirtyHealthNextTickAtMs = 0L careScore = (careScore + 1).coerceAtMost(999) } if (weakCause == "Dirty" && status == "Sick" && dirtiness < 10) { + careCycleNextTickAtMs = 0L status = "Recovering" recoveryCount = 0 } @@ -2567,6 +3814,15 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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, @@ -2578,12 +3834,20 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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) ) } @@ -2591,16 +3855,38 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } - LaunchedEffect(isSleeping, sleepMode) { - if (!isSleeping || sleepMode != "Nap") { + LaunchedEffect( + isSleeping, + sleepMode, + saveLoaded, + restoredManualNap + ) { + if ( + !saveLoaded || + !isSleeping || + sleepMode != "Nap" || + restoredManualNap + ) { return@LaunchedEffect } isTalking = false - val sleepTime = sleepOptionsMs.random() + 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 = sleepTime / sleepSteps + val stepDelay = remainingSleepMs / sleepSteps val startHealth = health val startHappiness = happiness @@ -2631,6 +3917,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { energy = 10 if (status in listOf("Sick", "Weak", "Recovering", "RecoverWeak")) { + careCycleNextTickAtMs = 0L status = "Calm" weakCause = "" overfeedCount = 0 @@ -2643,6 +3930,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { isSleeping = false sleepMode = "" + autoSleepStartedAtMs = 0L activityMood = "" if (framePropAnimation == "Nap") { @@ -2652,19 +3940,38 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } 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 = (235f * layoutScale).dp + val petFrameOuterPadding = (10f * layoutScale).dp + val petFrameInnerPadding = (12f * layoutScale).dp + val meterTopPadding = (16f * layoutScale).dp + val meterSpacing = (6f * layoutScale).dp + val controlsTopSpacing = (12f * layoutScale).dp + val actionSpacing = (8f * layoutScale).dp CompositionLocalProvider(LocalContentColor provides lcdDark) { Box( modifier = modifier .fillMaxSize() - .background(lcdBackground) + .background(petHomeBackground) ) { + val howToTopPadding = (28f * layoutScale * layoutScale).dp + Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(screenScrollState) - .padding(16.dp) - ) { + modifier = Modifier + .fillMaxWidth() + .verticalScroll(screenScrollState) + .padding(screenPadding) + ) { if (!needsPetName) { Row( modifier = Modifier.fillMaxWidth(), @@ -2676,7 +3983,21 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { ) { Text( text = displayPetName, - fontWeight = FontWeight.Bold + 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( @@ -2705,16 +4026,18 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { Text( text = "$location | $weather", fontWeight = FontWeight.Bold, - modifier = Modifier.clickable { + modifier = Modifier.clickable( + enabled = downtimeMode == DowntimeMode.None + ) { toggleLocation() } ) Text( - text = "Credits / Licenses", + text = "Extras", fontWeight = FontWeight.Bold, modifier = Modifier.clickable { - infoPanelType = "Credits" + infoPanelType = "Extras" infoPanelOpen = true statsMenuOpen = false careJournalOpen = false @@ -3017,6 +4340,16 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } ) + LCDSubButton( + text = "TEST BACKGROUND 30S", + enabled = true, + lcdDark = lcdDark, + lcdFaded = lcdFaded, + onClick = { + scheduleLCDPetBackgroundTest(context) + } + ) + LCDSubButton( text = "TEST HUNGER WARNING", enabled = !isFindingNewPet && !isTradingInPet && !isSleeping, @@ -3053,12 +4386,40 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { ) LCDSubButton( - text = "TEST ENERGY WARNING", + text = "TEST BG ENERGY 30S", enabled = !isFindingNewPet && !isTradingInPet && !isSleeping, lcdDark = lcdDark, lcdFaded = lcdFaded, onClick = { - energy = 1 + // 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 } ) @@ -3187,15 +4548,41 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { 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 && framePropAnimation.isEmpty(), + 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 } @@ -3203,7 +4590,10 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { LCDSubButton( text = "TEST WATCH TV", - enabled = !isSleeping && !isBathing && framePropAnimation.isEmpty(), + enabled = !isSleeping && + !isBathing && + !isTalking && + framePropAnimation.isEmpty(), lcdDark = lcdDark, lcdFaded = lcdFaded, onClick = { @@ -3211,6 +4601,8 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { selectedTvChannel = TvChannel.Weather location = "Inside" happyOutsideTrip = false + careMenuOpen = false + socializeMenuOpen = false testWantMenuOpen = false statsMenuOpen = false } @@ -3258,6 +4650,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } choosePottyFace() startFrameAnimation("PottyAccident") + pottyNextTickAtMs = 0L potty = 0 dirtiness = (dirtiness + 3).coerceAtMost(10) testWantMenuOpen = false @@ -3280,6 +4673,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { pottyStain = "" choosePottyFace() startFrameAnimation("PottyClean") + pottyNextTickAtMs = 0L potty = 0 testWantMenuOpen = false statsMenuOpen = false @@ -3293,6 +4687,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { lcdFaded = lcdFaded, onClick = { currentWant = "" + currentWantStartedAtMs = 0L testWantMenuOpen = false } ) @@ -3483,18 +4878,22 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 235.dp) - .padding(top = 10.dp, bottom = 10.dp) - .border(2.dp, lcdDark), + .heightIn(min = petFrameMinHeight) + .padding(top = petFrameOuterPadding, bottom = petFrameOuterPadding) + .background(lcdBackground) + .border(2.dp, petFrameForeground), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .fillMaxSize() - .padding(top = 12.dp, bottom = 12.dp), + .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" @@ -3505,22 +4904,33 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { Text( text = downtimeTitle, fontWeight = FontWeight.Bold, - color = lcdDark + color = petFrameForeground ) } else { - Text(text = healthBar) + Text( + text = healthBar, + color = petFrameForeground + ) } - Spacer(modifier = Modifier.height(10.dp)) + Spacer( + modifier = Modifier.height( + if (downtimeActive) { + (4f * layoutScale).dp + } else { + (10f * layoutScale).dp + } + ) + ) if (testingMode && downtimeMode == DowntimeMode.TouchGrass) { TouchGrassActivityScreen( - lcdDark = lcdDark, + lcdDark = petFrameForeground, onGrassTap = {} ) } else if (testingMode && downtimeMode == DowntimeMode.WatchTv) { WatchTvActivityScreen( - lcdDark = lcdDark, + lcdDark = petFrameForeground, selectedChannel = selectedTvChannel, weatherLabel = weather, onChannelSelected = { selectedTvChannel = it } @@ -3532,42 +4942,57 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { lineHeight = 24.sp, fontFamily = FontFamily.Monospace, textAlign = TextAlign.Center, - color = if (displayStatus == "Recovering") sickGreen else petColor + color = + if (petHomeColor == "K") { + petFrameForeground + } else if (displayStatus == "Recovering") { + sickGreen + } else { + petColor + } ) } - Spacer(modifier = Modifier.height(8.dp)) + 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 = lcdDark, - softWrap = false, - maxLines = 1 - ) + 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)) + 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, - textAlign = TextAlign.Center - ) + 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 + ) + } } - if ((isSleeping && location == "Inside") || roughWakeDarkFlash) { Box( modifier = Modifier - .fillMaxSize() + .matchParentSize() .background(Color.Gray.copy(alpha = 0.38f)) ) } @@ -3582,7 +5007,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { Text( text = "FINDING PET...", fontWeight = FontWeight.Bold, - color = lcdDark + color = petFrameForeground ) } } @@ -3595,7 +5020,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { modifier = Modifier .width(androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp.dp) .height(androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp) - .background(lcdBackground) + .background(petHomeBackground) .border(2.dp, lcdDark) .padding(10.dp) ) { @@ -3607,10 +5032,12 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = if (infoPanelType == "HowTo") { - "HOW TO" - } else { - "CREDITS / LICENSES" + text = when (infoPanelType) { + "HowTo" -> "HOW TO" + "Extras" -> "EXTRAS" + "Appearance" -> "APPEARANCE" + "Renovation" -> "HOME RENOVATION" + else -> "CREDITS / LICENSES" }, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, @@ -3634,6 +5061,233 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } ) } + } 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: ")) { @@ -3710,7 +5364,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { text = "CLOSE", fontWeight = FontWeight.Bold, modifier = Modifier - .background(lcdBackground) + .background(petHomeBackground) .border(1.dp, lcdDark) .clickable { infoPanelOpen = false @@ -3722,7 +5376,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { text = "MOVE", fontWeight = FontWeight.Bold, modifier = Modifier - .background(lcdBackground) + .background(petHomeBackground) .border(1.dp, lcdDark) .clickable { infoPanelButtonCorner = when (infoPanelButtonCorner) { @@ -3739,7 +5393,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { text = "MOVE", fontWeight = FontWeight.Bold, modifier = Modifier - .background(lcdBackground) + .background(petHomeBackground) .border(1.dp, lcdDark) .clickable { infoPanelButtonCorner = when (infoPanelButtonCorner) { @@ -3756,7 +5410,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { text = "CLOSE", fontWeight = FontWeight.Bold, modifier = Modifier - .background(lcdBackground) + .background(petHomeBackground) .border(1.dp, lcdDark) .clickable { infoPanelOpen = false @@ -3771,88 +5425,137 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } Column( - modifier = Modifier.padding(top = 16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + modifier = Modifier.padding(top = meterTopPadding), + verticalArrangement = Arrangement.spacedBy(meterSpacing) ) { + val meterLabelWidth = 120.dp + val meterBarPadding = 8.dp + Row { Text( text = "Happiness", - modifier = Modifier.width(112.dp), + modifier = Modifier.width(meterLabelWidth), textAlign = TextAlign.End, maxLines = 1, softWrap = false ) - Text(text = " $happinessBar") + Text( + text = happinessBar, + modifier = Modifier.padding(start = meterBarPadding), + fontFamily = FontFamily.Monospace, + maxLines = 1, + softWrap = false + ) } Row { Text( text = "Energy", - modifier = Modifier.width(112.dp), + modifier = Modifier.width(meterLabelWidth), textAlign = TextAlign.End, maxLines = 1, softWrap = false ) - Text(text = " $energyBar") + Text( + text = energyBar, + modifier = Modifier.padding(start = meterBarPadding), + fontFamily = FontFamily.Monospace, + maxLines = 1, + softWrap = false + ) } Row { Text( text = "Hunger", - modifier = Modifier.width(112.dp), + modifier = Modifier.width(meterLabelWidth), textAlign = TextAlign.End, maxLines = 1, softWrap = false ) - Text(text = " $hungerBar") + Text( + text = hungerBar, + modifier = Modifier.padding(start = meterBarPadding), + fontFamily = FontFamily.Monospace, + maxLines = 1, + softWrap = false + ) } Row { Text( text = "Dirtiness", - modifier = Modifier.width(112.dp), + modifier = Modifier.width(meterLabelWidth), textAlign = TextAlign.End, maxLines = 1, softWrap = false ) - Text(text = " $dirtinessBar") + Text( + text = dirtinessBar, + modifier = Modifier.padding(start = meterBarPadding), + fontFamily = FontFamily.Monospace, + maxLines = 1, + softWrap = false + ) } Row { Text( text = "Potty", - modifier = Modifier.width(112.dp), + modifier = Modifier.width(meterLabelWidth), textAlign = TextAlign.End, maxLines = 1, softWrap = false ) - Text(text = " $pottyBar") + Text( + text = pottyBar, + modifier = Modifier.padding(start = meterBarPadding), + fontFamily = FontFamily.Monospace, + maxLines = 1, + softWrap = false + ) } Row { Text( text = "Boredom", - modifier = Modifier.width(112.dp), + modifier = Modifier.width(meterLabelWidth), textAlign = TextAlign.End, maxLines = 1, softWrap = false ) - Text(text = " $boredomBar") + Text( + text = boredomBar, + modifier = Modifier.padding(start = meterBarPadding), + fontFamily = FontFamily.Monospace, + maxLines = 1, + softWrap = false + ) } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(controlsTopSpacing)) Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(actionSpacing) ) { - val actionInProgress = isTradingInPet || isFindingNewPet || isSleeping || isBathing || framePropAnimation.isNotEmpty() || + 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" && + val canFeed = + canStartAction && + location == "Inside" && + (currentWant == "Snack" || hunger >= 3) && !(status in listOf( "Sick", "Weak", @@ -3868,7 +5571,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { val canNap = canStartAction && location == "Inside" && energy < 10 val canPotty = canStartAction && location == "Outside" && potty > 0 - val canWalk = canStartAction && location == "Outside" && energy > 0 && status !in listOf( + val canWalk = canStartAction && location == "Outside" && weather != "Snow" && energy > 0 && status !in listOf( "Sick", "Recovering", "Weak", @@ -3990,7 +5693,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { ) } }, - onRelease = { + onRelease = { _ -> endTalk() } ) @@ -4025,7 +5728,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .padding(top = 28.dp, bottom = 4.dp) + .padding(top = howToTopPadding, bottom = 4.dp) .clickable { infoPanelType = "HowTo" infoPanelOpen = true @@ -4039,6 +5742,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { ) } } + } } } diff --git a/app/src/main/java/today/notfeelingit/lcdpet/PetStorage.kt b/app/src/main/java/today/notfeelingit/lcdpet/PetStorage.kt index 29a6baf..03a2988 100644 --- a/app/src/main/java/today/notfeelingit/lcdpet/PetStorage.kt +++ b/app/src/main/java/today/notfeelingit/lcdpet/PetStorage.kt @@ -15,6 +15,7 @@ val Context.lcdPetDataStore by preferencesDataStore(name = "lcdpet_save") data class LCDPetRecordSave( val bond: Int = 0, val petAgeDays: Int = 0, + val nextAgeAtMs: Long = 0L, val careScore: Int = 0, val neglectScore: Int = 0, val hunger: Int = 5, @@ -24,6 +25,15 @@ data class LCDPetRecordSave( val dirtiness: Int = 0, val potty: Int = 0, val boredom: Int = 0, + val starvingCount: Int = 0, + val recoveryCount: Int = 0, + val sickCount: Int = 0, + val playEnergyCount: Int = 0, + val sameActionStreak: Int = 0, + val boredomTriggerAt: Int = 0, + val boredomPenaltyCount: Int = 0, + val ultraTrollTriggered: Boolean = false, + val lastActionName: String = "", val location: String = "Inside", val weather: String = "Sunny", val status: String = "Nervous", @@ -35,19 +45,34 @@ data class LCDPetRecordSave( val tradeInProgress: Int = 0, val tradeInStartedAtMs: Long = 0L, val currentWant: String = "", + val currentWantStartedAtMs: Long = 0L, + val nextWantAtMs: Long = 0L, val petName: String = "", val petTemplateId: String = "classic", val isSleeping: Boolean = false, val sleepMode: String = "", val roughWakeAlert: Boolean = false, val autoSleepStartedAtMs: Long = 0L, + val outsideEnergyNextTickAtMs: Long = 0L, + val nextWeatherAtMs: Long = 0L, + val bondReadyAtMs: Long = 0L, + val careCycleNextTickAtMs: Long = 0L, + val rainHappinessNextTickAtMs: Long = 0L, + val pottyNextTickAtMs: Long = 0L, + val dirtyHealthNextTickAtMs: Long = 0L, val careJournalRaw: String = "", val lastSavedAtMs: Long = System.currentTimeMillis() ) +data class LCDPetCreatedWant( + val wantName: String, + val petName: String +) + private object LCDPetSaveKeys { val BOND = intPreferencesKey("bond") val PET_AGE_DAYS = intPreferencesKey("pet_age_days") + val NEXT_AGE_AT_MS = longPreferencesKey("next_age_at_ms") val CARE_SCORE = intPreferencesKey("care_score") val NEGLECT_SCORE = intPreferencesKey("neglect_score") val HUNGER = intPreferencesKey("hunger") @@ -57,6 +82,17 @@ private object LCDPetSaveKeys { val DIRTINESS = intPreferencesKey("dirtiness") val POTTY = intPreferencesKey("potty") val BOREDOM = intPreferencesKey("boredom") + val STARVING_COUNT = intPreferencesKey("starving_count") + val RECOVERY_COUNT = intPreferencesKey("recovery_count") + val SICK_COUNT = intPreferencesKey("sick_count") + val PLAY_ENERGY_COUNT = intPreferencesKey("play_energy_count") + val SAME_ACTION_STREAK = intPreferencesKey("same_action_streak") + val BOREDOM_TRIGGER_AT = intPreferencesKey("boredom_trigger_at") + val BOREDOM_PENALTY_COUNT = + intPreferencesKey("boredom_penalty_count") + val ULTRA_TROLL_TRIGGERED = + booleanPreferencesKey("ultra_troll_triggered") + val LAST_ACTION_NAME = stringPreferencesKey("last_action_name") val LOCATION = stringPreferencesKey("location") val WEATHER = stringPreferencesKey("weather") val STATUS = stringPreferencesKey("status") @@ -68,13 +104,32 @@ private object LCDPetSaveKeys { val TRADE_IN_PROGRESS = intPreferencesKey("trade_in_progress") val TRADE_IN_STARTED_AT_MS = longPreferencesKey("trade_in_started_at_ms") val CURRENT_WANT = stringPreferencesKey("current_want") + val CURRENT_WANT_STARTED_AT_MS = + longPreferencesKey("current_want_started_at_ms") + val NEXT_WANT_AT_MS = longPreferencesKey("next_want_at_ms") val PET_NAME = stringPreferencesKey("pet_name") val PET_TEMPLATE_ID = stringPreferencesKey("pet_template_id") val IS_SLEEPING = booleanPreferencesKey("is_sleeping") val SLEEP_MODE = stringPreferencesKey("sleep_mode") val ROUGH_WAKE_ALERT = booleanPreferencesKey("rough_wake_alert") val AUTO_SLEEP_STARTED_AT_MS = longPreferencesKey("auto_sleep_started_at_ms") + val OUTSIDE_ENERGY_NEXT_TICK_AT_MS = + longPreferencesKey("outside_energy_next_tick_at_ms") + val NEXT_WEATHER_AT_MS = + longPreferencesKey("next_weather_at_ms") + val BOND_READY_AT_MS = + longPreferencesKey("bond_ready_at_ms") + val CARE_CYCLE_NEXT_TICK_AT_MS = + longPreferencesKey("care_cycle_next_tick_at_ms") + val RAIN_HAPPINESS_NEXT_TICK_AT_MS = + longPreferencesKey("rain_happiness_next_tick_at_ms") + val POTTY_NEXT_TICK_AT_MS = + longPreferencesKey("potty_next_tick_at_ms") + val DIRTY_HEALTH_NEXT_TICK_AT_MS = + longPreferencesKey("dirty_health_next_tick_at_ms") val CARE_JOURNAL = stringPreferencesKey("care_journal") + val APPEARANCE = stringPreferencesKey("appearance") + val PET_HOME_COLOR = stringPreferencesKey("pet_home_color") val LAST_SAVED_AT_MS = longPreferencesKey("last_saved_at_ms") } @@ -83,6 +138,8 @@ val Context.lcdPetRecordSaveFlow: Flow LCDPetRecordSave( bond = preferences[LCDPetSaveKeys.BOND] ?: 0, petAgeDays = preferences[LCDPetSaveKeys.PET_AGE_DAYS] ?: 0, + nextAgeAtMs = + preferences[LCDPetSaveKeys.NEXT_AGE_AT_MS] ?: 0L, careScore = preferences[LCDPetSaveKeys.CARE_SCORE] ?: 0, neglectScore = preferences[LCDPetSaveKeys.NEGLECT_SCORE] ?: 0, hunger = preferences[LCDPetSaveKeys.HUNGER] ?: 5, @@ -92,6 +149,24 @@ val Context.lcdPetRecordSaveFlow: Flow dirtiness = preferences[LCDPetSaveKeys.DIRTINESS] ?: 0, potty = preferences[LCDPetSaveKeys.POTTY] ?: 0, boredom = preferences[LCDPetSaveKeys.BOREDOM] ?: 0, + starvingCount = + preferences[LCDPetSaveKeys.STARVING_COUNT] ?: 0, + recoveryCount = + preferences[LCDPetSaveKeys.RECOVERY_COUNT] ?: 0, + sickCount = + preferences[LCDPetSaveKeys.SICK_COUNT] ?: 0, + playEnergyCount = + preferences[LCDPetSaveKeys.PLAY_ENERGY_COUNT] ?: 0, + sameActionStreak = + preferences[LCDPetSaveKeys.SAME_ACTION_STREAK] ?: 0, + boredomTriggerAt = + preferences[LCDPetSaveKeys.BOREDOM_TRIGGER_AT] ?: 0, + boredomPenaltyCount = + preferences[LCDPetSaveKeys.BOREDOM_PENALTY_COUNT] ?: 0, + ultraTrollTriggered = + preferences[LCDPetSaveKeys.ULTRA_TROLL_TRIGGERED] ?: false, + lastActionName = + preferences[LCDPetSaveKeys.LAST_ACTION_NAME] ?: "", location = preferences[LCDPetSaveKeys.LOCATION] ?: "Inside", weather = preferences[LCDPetSaveKeys.WEATHER] ?: "Sunny", status = preferences[LCDPetSaveKeys.STATUS] ?: "Nervous", @@ -103,17 +178,73 @@ val Context.lcdPetRecordSaveFlow: Flow tradeInProgress = preferences[LCDPetSaveKeys.TRADE_IN_PROGRESS] ?: 0, tradeInStartedAtMs = preferences[LCDPetSaveKeys.TRADE_IN_STARTED_AT_MS] ?: 0L, currentWant = preferences[LCDPetSaveKeys.CURRENT_WANT] ?: "", + currentWantStartedAtMs = + preferences[LCDPetSaveKeys.CURRENT_WANT_STARTED_AT_MS] ?: 0L, + nextWantAtMs = + preferences[LCDPetSaveKeys.NEXT_WANT_AT_MS] ?: 0L, petName = preferences[LCDPetSaveKeys.PET_NAME] ?: "", petTemplateId = preferences[LCDPetSaveKeys.PET_TEMPLATE_ID] ?: "classic", isSleeping = preferences[LCDPetSaveKeys.IS_SLEEPING] ?: false, sleepMode = preferences[LCDPetSaveKeys.SLEEP_MODE] ?: "", roughWakeAlert = preferences[LCDPetSaveKeys.ROUGH_WAKE_ALERT] ?: false, autoSleepStartedAtMs = preferences[LCDPetSaveKeys.AUTO_SLEEP_STARTED_AT_MS] ?: 0L, + outsideEnergyNextTickAtMs = + preferences[LCDPetSaveKeys.OUTSIDE_ENERGY_NEXT_TICK_AT_MS] ?: 0L, + nextWeatherAtMs = + preferences[LCDPetSaveKeys.NEXT_WEATHER_AT_MS] ?: 0L, + bondReadyAtMs = + preferences[LCDPetSaveKeys.BOND_READY_AT_MS] ?: 0L, + careCycleNextTickAtMs = + preferences[LCDPetSaveKeys.CARE_CYCLE_NEXT_TICK_AT_MS] ?: 0L, + rainHappinessNextTickAtMs = + preferences[LCDPetSaveKeys.RAIN_HAPPINESS_NEXT_TICK_AT_MS] ?: 0L, + pottyNextTickAtMs = + preferences[LCDPetSaveKeys.POTTY_NEXT_TICK_AT_MS] ?: 0L, + dirtyHealthNextTickAtMs = + preferences[LCDPetSaveKeys.DIRTY_HEALTH_NEXT_TICK_AT_MS] ?: 0L, careJournalRaw = preferences[LCDPetSaveKeys.CARE_JOURNAL] ?: "", lastSavedAtMs = preferences[LCDPetSaveKeys.LAST_SAVED_AT_MS] ?: System.currentTimeMillis() ) } +val Context.lcdPetAppearanceFlow: Flow + get() = lcdPetDataStore.data.map { preferences -> + preferences[LCDPetSaveKeys.APPEARANCE] ?: "System" + } + +suspend fun Context.setLCDPetAppearance(appearance: String) { + val savedAppearance = + appearance.takeIf { it in setOf("System", "Light", "Dark") } + ?: "System" + + lcdPetDataStore.edit { preferences -> + preferences[LCDPetSaveKeys.APPEARANCE] = savedAppearance + } +} + +val Context.lcdPetHomeColorFlow: Flow + get() = lcdPetDataStore.data.map { preferences -> + preferences[LCDPetSaveKeys.PET_HOME_COLOR] ?: "LCD" + } + +suspend fun Context.setLCDPetHomeColor(homeColor: String) { + val savedHomeColor = + homeColor.takeIf { + it in setOf("LCD", "R", "G", "B", "Y", "P", "K") + } ?: "LCD" + + lcdPetDataStore.edit { preferences -> + preferences[LCDPetSaveKeys.PET_HOME_COLOR] = savedHomeColor + } +} + +suspend fun Context.setLCDPetNextAgeAtMs(nextAgeAtMs: Long) { + lcdPetDataStore.edit { preferences -> + preferences[LCDPetSaveKeys.NEXT_AGE_AT_MS] = + nextAgeAtMs.coerceAtLeast(0L) + } +} + suspend fun Context.saveLCDPetStats( bond: Int, petAgeDays: Int, @@ -126,6 +257,15 @@ suspend fun Context.saveLCDPetStats( dirtiness: Int, potty: Int, boredom: Int, + starvingCount: Int, + recoveryCount: Int, + sickCount: Int, + playEnergyCount: Int, + sameActionStreak: Int, + boredomTriggerAt: Int, + boredomPenaltyCount: Int, + ultraTrollTriggered: Boolean, + lastActionName: String, location: String, weather: String, status: String, @@ -143,6 +283,14 @@ suspend fun Context.saveLCDPetStats( sleepMode: String, roughWakeAlert: Boolean, autoSleepStartedAtMs: Long, + currentWantStartedAtMs: Long = 0L, + nextWantAtMs: Long = 0L, + nextWeatherAtMs: Long, + bondReadyAtMs: Long, + careCycleNextTickAtMs: Long, + rainHappinessNextTickAtMs: Long, + pottyNextTickAtMs: Long, + dirtyHealthNextTickAtMs: Long, careJournalRaw: String? = null ) { lcdPetDataStore.edit { preferences -> @@ -157,6 +305,24 @@ suspend fun Context.saveLCDPetStats( preferences[LCDPetSaveKeys.DIRTINESS] = dirtiness preferences[LCDPetSaveKeys.POTTY] = potty preferences[LCDPetSaveKeys.BOREDOM] = boredom + preferences[LCDPetSaveKeys.STARVING_COUNT] = + starvingCount.coerceAtLeast(0) + preferences[LCDPetSaveKeys.RECOVERY_COUNT] = + recoveryCount.coerceAtLeast(0) + preferences[LCDPetSaveKeys.SICK_COUNT] = + sickCount.coerceAtLeast(0) + preferences[LCDPetSaveKeys.PLAY_ENERGY_COUNT] = + playEnergyCount.coerceAtLeast(0) + preferences[LCDPetSaveKeys.SAME_ACTION_STREAK] = + sameActionStreak.coerceAtLeast(0) + preferences[LCDPetSaveKeys.BOREDOM_TRIGGER_AT] = + boredomTriggerAt.coerceAtLeast(0) + preferences[LCDPetSaveKeys.BOREDOM_PENALTY_COUNT] = + boredomPenaltyCount.coerceAtLeast(0) + preferences[LCDPetSaveKeys.ULTRA_TROLL_TRIGGERED] = + ultraTrollTriggered + preferences[LCDPetSaveKeys.LAST_ACTION_NAME] = + lastActionName preferences[LCDPetSaveKeys.LOCATION] = location preferences[LCDPetSaveKeys.WEATHER] = weather preferences[LCDPetSaveKeys.STATUS] = status @@ -168,6 +334,22 @@ suspend fun Context.saveLCDPetStats( preferences[LCDPetSaveKeys.TRADE_IN_PROGRESS] = tradeInProgress preferences[LCDPetSaveKeys.TRADE_IN_STARTED_AT_MS] = tradeInStartedAtMs preferences[LCDPetSaveKeys.CURRENT_WANT] = currentWant + preferences[LCDPetSaveKeys.CURRENT_WANT_STARTED_AT_MS] = + currentWantStartedAtMs.coerceAtLeast(0L) + preferences[LCDPetSaveKeys.NEXT_WANT_AT_MS] = + nextWantAtMs.coerceAtLeast(0L) + preferences[LCDPetSaveKeys.NEXT_WEATHER_AT_MS] = + nextWeatherAtMs.coerceAtLeast(0L) + preferences[LCDPetSaveKeys.BOND_READY_AT_MS] = + bondReadyAtMs.coerceAtLeast(0L) + preferences[LCDPetSaveKeys.CARE_CYCLE_NEXT_TICK_AT_MS] = + careCycleNextTickAtMs.coerceAtLeast(0L) + preferences[LCDPetSaveKeys.RAIN_HAPPINESS_NEXT_TICK_AT_MS] = + rainHappinessNextTickAtMs.coerceAtLeast(0L) + preferences[LCDPetSaveKeys.POTTY_NEXT_TICK_AT_MS] = + pottyNextTickAtMs.coerceAtLeast(0L) + preferences[LCDPetSaveKeys.DIRTY_HEALTH_NEXT_TICK_AT_MS] = + dirtyHealthNextTickAtMs.coerceAtLeast(0L) preferences[LCDPetSaveKeys.PET_NAME] = petName preferences[LCDPetSaveKeys.PET_TEMPLATE_ID] = petTemplateId preferences[LCDPetSaveKeys.IS_SLEEPING] = isSleeping @@ -180,3 +362,88 @@ suspend fun Context.saveLCDPetStats( preferences[LCDPetSaveKeys.LAST_SAVED_AT_MS] = System.currentTimeMillis() } } + +suspend fun Context.createLCDPetWantIfDue( + nowMs: Long = System.currentTimeMillis() +): LCDPetCreatedWant? { + var createdWant: LCDPetCreatedWant? = null + + lcdPetDataStore.edit { preferences -> + val currentWant = + preferences[LCDPetSaveKeys.CURRENT_WANT].orEmpty() + val nextWantAtMs = + preferences[LCDPetSaveKeys.NEXT_WANT_AT_MS] ?: 0L + val petName = + preferences[LCDPetSaveKeys.PET_NAME].orEmpty() + val location = + preferences[LCDPetSaveKeys.LOCATION] ?: "Inside" + val bond = + preferences[LCDPetSaveKeys.BOND] ?: 0 + val status = + preferences[LCDPetSaveKeys.STATUS] ?: "Nervous" + val isSleeping = + preferences[LCDPetSaveKeys.IS_SLEEPING] ?: false + val isTradingInPet = + preferences[LCDPetSaveKeys.IS_TRADING_IN_PET] ?: false + val isFindingNewPet = + preferences[LCDPetSaveKeys.IS_FINDING_NEW_PET] ?: false + + val canCreateWant = + currentWant.isBlank() && + nextWantAtMs > 0L && + nowMs >= nextWantAtMs && + petName.isNotBlank() && + status !in setOf("Sick", "Weak") && + !isSleeping && + !isTradingInPet && + !isFindingNewPet + + if (!canCreateWant) { + return@edit + } + + val possibleWants = + if (location == "Inside") { + listOf("Snack", "Play", "Pet", "Talk", "Outside") + + if (bond >= 100) listOf("Bath") else emptyList() + } else { + listOf("Play", "Pet", "Talk") + } + + val wantName = possibleWants.random() + + preferences[LCDPetSaveKeys.CURRENT_WANT] = wantName + preferences[LCDPetSaveKeys.CURRENT_WANT_STARTED_AT_MS] = nowMs + preferences[LCDPetSaveKeys.NEXT_WANT_AT_MS] = 0L + preferences[LCDPetSaveKeys.LAST_SAVED_AT_MS] = nowMs + + createdWant = LCDPetCreatedWant( + wantName = wantName, + petName = petName + ) + } + + return createdWant +} + +suspend fun Context.clearLCDPetWants() { + lcdPetDataStore.edit { preferences -> + preferences[LCDPetSaveKeys.CURRENT_WANT] = "" + preferences[LCDPetSaveKeys.CURRENT_WANT_STARTED_AT_MS] = 0L + preferences[LCDPetSaveKeys.NEXT_WANT_AT_MS] = 0L + } +} + +suspend fun Context.setLCDPetNextWantAtMs(nextWantAtMs: Long) { + lcdPetDataStore.edit { preferences -> + preferences[LCDPetSaveKeys.NEXT_WANT_AT_MS] = + nextWantAtMs.coerceAtLeast(0L) + } +} + +suspend fun Context.setLCDPetOutsideEnergyNextTickAtMs(nextTickAtMs: Long) { + lcdPetDataStore.edit { preferences -> + preferences[LCDPetSaveKeys.OUTSIDE_ENERGY_NEXT_TICK_AT_MS] = + nextTickAtMs.coerceAtLeast(0L) + } +} diff --git a/app/src/main/java/today/notfeelingit/lcdpet/ui/theme/Theme.kt b/app/src/main/java/today/notfeelingit/lcdpet/ui/theme/Theme.kt index fec268b..a33f2a9 100644 --- a/app/src/main/java/today/notfeelingit/lcdpet/ui/theme/Theme.kt +++ b/app/src/main/java/today/notfeelingit/lcdpet/ui/theme/Theme.kt @@ -1,6 +1,8 @@ package today.notfeelingit.lcdpet.ui.theme +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable @@ -8,25 +10,24 @@ private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 +) - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 ) @Composable fun LCDPetTheme( + darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { MaterialTheme( - colorScheme = LightColorScheme, + colorScheme = + if (darkTheme) DarkColorScheme + else LightColorScheme, typography = Typography, content = content ) -} \ No newline at end of file +} diff --git a/app/src/main/res/raw/need_urgent.ogg b/app/src/main/res/raw/need_urgent.ogg new file mode 100644 index 0000000..746147f Binary files /dev/null and b/app/src/main/res/raw/need_urgent.ogg differ diff --git a/app/src/main/res/raw/pet_eating.ogg b/app/src/main/res/raw/pet_eating.ogg new file mode 100644 index 0000000..a0ced42 Binary files /dev/null and b/app/src/main/res/raw/pet_eating.ogg differ diff --git a/app/src/main/res/raw/pet_excited.ogg b/app/src/main/res/raw/pet_excited.ogg new file mode 100644 index 0000000..eddb5eb Binary files /dev/null and b/app/src/main/res/raw/pet_excited.ogg differ diff --git a/app/src/main/res/raw/pet_sick.ogg b/app/src/main/res/raw/pet_sick.ogg new file mode 100644 index 0000000..c98fa99 Binary files /dev/null and b/app/src/main/res/raw/pet_sick.ogg differ diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt new file mode 100644 index 0000000..745bb13 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3.txt @@ -0,0 +1,10 @@ +LCDPet 1.2 adds: + +• Snow weather and Home Renovation colors +• Full meals, Snack Wants, and improved Want safety +• Pet activity sounds and sound-detected TALK +• Better notification sound, vibration, and foreground behavior +• Responsive layouts, aligned status meters, and appearance controls +• Continuous TV channels that resume in place +• Improved Age, Nap, care-cycle, weather, Bond, Potty, and health persistence +• Numerous notification, lifecycle, and stability fixes