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