| 1 | package codewhale.pet |
| 2 | |
| 3 | import android.content.BroadcastReceiver |
| 4 | import android.content.Context |
| 5 | import android.content.Intent |
| 6 | import android.content.IntentFilter |
| 7 | import android.database.ContentObserver |
| 8 | import android.media.AudioManager |
| 9 | import android.os.Bundle |
| 10 | import android.os.Handler |
| 11 | import android.os.Looper |
| 12 | import android.provider.Settings |
| 13 | import androidx.activity.ComponentActivity |
| 14 | import androidx.activity.SystemBarStyle |
| 15 | import androidx.activity.compose.rememberLauncherForActivityResult |
| 16 | import androidx.activity.compose.setContent |
| 17 | import androidx.activity.enableEdgeToEdge |
| 18 | import androidx.activity.result.contract.ActivityResultContracts |
| 19 | import androidx.activity.viewModels |
| 20 | import androidx.compose.foundation.layout.* |
| 21 | import androidx.compose.foundation.lazy.LazyColumn |
| 22 | import androidx.compose.foundation.lazy.items |
| 23 | import androidx.compose.foundation.rememberScrollState |
| 24 | import androidx.compose.foundation.verticalScroll |
| 25 | import androidx.compose.material3.* |
| 26 | import androidx.compose.runtime.* |
| 27 | import androidx.compose.runtime.saveable.rememberSaveable |
| 28 | import androidx.compose.ui.Alignment |
| 29 | import androidx.compose.ui.Modifier |
| 30 | import androidx.compose.ui.graphics.Color |
| 31 | import androidx.compose.ui.text.font.FontFamily |
| 32 | import androidx.compose.ui.unit.dp |
| 33 | import androidx.core.content.ContextCompat |
| 34 | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 35 | |
| 36 | class PetActivity : ComponentActivity() { |
| 37 | private val model by viewModels<PetViewModel>() |
| 38 | private val noise = object : BroadcastReceiver() { |
| 39 | override fun onReceive(context: Context, intent: Intent) { model.setSound(false) } |
| 40 | } |
| 41 | private val motion = object : ContentObserver(Handler(Looper.getMainLooper())) { |
| 42 | override fun onChange(selfChange: Boolean) = readMotion() |
| 43 | } |
| 44 | private fun readMotion() { |
| 45 | model.setSystemStill(Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f) |
| 46 | } |
| 47 | override fun onCreate(savedInstanceState: Bundle?) { |
| 48 | super.onCreate(savedInstanceState) |
| 49 | enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), |
| 50 | navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)) |
| 51 | readMotion() |
| 52 | contentResolver.registerContentObserver(Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE), false, motion) |
| 53 | ContextCompat.registerReceiver(this, noise, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), ContextCompat.RECEIVER_NOT_EXPORTED) |
| 54 | setContent { |
| 55 | MaterialTheme(colorScheme = darkColorScheme(primary = Color(0xff73c9b5), background = Color(0xff071319), |
| 56 | surface = Color(0xff071319), onSurface = Color(0xffd9e8e6), onSurfaceVariant = Color(0xff9aacaf))) { |
| 57 | Surface(Modifier.fillMaxSize()) { PetScreen(model) } |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | override fun onResume() { super.onResume(); readMotion(); model.setActive(true) } |
| 62 | override fun onPause() { model.setActive(false); super.onPause() } |
| 63 | override fun onDestroy() { |
| 64 | contentResolver.unregisterContentObserver(motion); unregisterReceiver(noise) |
| 65 | super.onDestroy() |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | @Composable |
| 70 | private fun PetScreen(model: PetViewModel) { |
| 71 | val ui by model.ui.collectAsStateWithLifecycle() |
| 72 | val import = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { it?.let(model::importRecording) } |
| 73 | val join = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { it?.let(model::joinShared) } |
| 74 | val follow = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { it?.let(model::followLocalTape) } |
| 75 | val export = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecording) } |
| 76 | val recovery = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecovery) } |
| 77 | var selectedArchive by rememberSaveable { mutableStateOf<String?>(null) } |
| 78 | val archived = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> |
| 79 | val name = selectedArchive |
| 80 | if (uri != null && name != null) model.exportArchive(uri, name) |
| 81 | selectedArchive = null |
| 82 | } |
| 83 | var showArchives by remember { mutableStateOf(false) } |
| 84 | if (showArchives) AlertDialog(onDismissRequest = { showArchives = false }, title = { Text("Earlier recordings") }, |
| 85 | text = { LazyColumn(Modifier.heightIn(max = 320.dp)) { |
| 86 | items(ui.archives, key = { it }) { name -> |
| 87 | val seconds = (name.split('-').dropLast(1).lastOrNull()?.toLongOrNull() ?: 0) / 30 |
| 88 | TextButton(onClick = { selectedArchive = name; showArchives = false; archived.launch("codewhale-pet-earlier.json") }) { |
| 89 | Text("Through ${seconds / 3600}:${"%02d".format(seconds / 60 % 60)}:${"%02d".format(seconds % 60)}") |
| 90 | } |
| 91 | } |
| 92 | } }, confirmButton = { TextButton(onClick = { showArchives = false }) { Text("Close") } }) |
| 93 | var restart by remember { mutableStateOf(false) } |
| 94 | var reload by remember { mutableStateOf(false) } |
| 95 | if (restart) AlertDialog(onDismissRequest = { restart = false }, title = { Text("Start a fresh habitat?") }, |
| 96 | text = { Text("The saved world will be kept as a recovery copy. Unsaved changes are discarded. Export the recording first to keep the current visit.") }, |
| 97 | confirmButton = { TextButton(onClick = { restart = false; model.restart() }) { Text("Start fresh") } }, |
| 98 | dismissButton = { TextButton(onClick = { restart = false }) { Text("Cancel") } }) |
| 99 | if (reload) AlertDialog(onDismissRequest = { reload = false }, title = { Text("Reopen the saved habitat?") }, |
| 100 | text = { Text("Unsaved changes will be discarded. Export the recording first to keep the current visit.") }, |
| 101 | confirmButton = { TextButton(onClick = { reload = false; model.reload() }) { Text("Reopen saved") } }, |
| 102 | dismissButton = { TextButton(onClick = { reload = false }) { Text("Cancel") } }) |
| 103 | Column(Modifier.safeDrawingPadding().fillMaxSize()) { |
| 104 | Row(Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { |
| 105 | Text("Codewhale", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f)) |
| 106 | var menu by remember { mutableStateOf(false) } |
| 107 | Box { |
| 108 | TextButton(onClick = { menu = true }) { Text("More") } |
| 109 | DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { |
| 110 | DropdownMenuItem(text = { Text("Import recording") }, onClick = { menu = false; import.launch(arrayOf("application/json", "text/plain", "application/octet-stream")) }) |
| 111 | DropdownMenuItem(text = { Text("Join shared pet") }, onClick = { menu = false; join.launch(arrayOf("application/json", "*/*")) }) |
| 112 | DropdownMenuItem(text = { Text("Follow file study") }, onClick = { menu = false; follow.launch(arrayOf("*/*")) }) |
| 113 | DropdownMenuItem(text = { Text("Export recording") }, enabled = ui.scene != null, onClick = { menu = false; export.launch("codewhale-pet.json") }) |
| 114 | DropdownMenuItem(text = { Text("Earlier recordings") }, enabled = ui.archives.isNotEmpty(), onClick = { menu = false; showArchives = true }) |
| 115 | DropdownMenuItem(text = { Text("Reopen saved habitat") }, onClick = { menu = false; reload = true }) |
| 116 | DropdownMenuItem(text = { Text("Start fresh habitat") }, enabled = ui.scene != null && ui.mode != PetMode.SHARED, onClick = { menu = false; restart = true }) |
| 117 | DropdownMenuItem(text = { Text("Export previous world") }, enabled = ui.canExportRecovery, |
| 118 | onClick = { menu = false; recovery.launch("codewhale-pet-recovery.json") }) |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { |
| 123 | val controlsHeight = maxHeight * 0.55f |
| 124 | if (maxWidth > maxHeight && maxWidth > 540.dp) { |
| 125 | Row(Modifier.fillMaxSize()) { |
| 126 | Habitat(ui, Modifier.weight(1f).fillMaxHeight()) |
| 127 | Controls(ui, model, Modifier.width(256.dp).fillMaxHeight().verticalScroll(rememberScrollState())) |
| 128 | } |
| 129 | } else { |
| 130 | Column(Modifier.fillMaxSize()) { |
| 131 | Habitat(ui, Modifier.weight(1f).fillMaxWidth()) |
| 132 | Controls(ui, model, Modifier.fillMaxWidth().heightIn(max = controlsHeight).verticalScroll(rememberScrollState())) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | @Composable |
| 140 | private fun Habitat(ui: PetUiState, modifier: Modifier) { |
| 141 | Column(modifier) { |
| 142 | Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { |
| 143 | ui.scene?.let { CodewhalePet(it) } ?: if (ui.message == null) CircularProgressIndicator() |
| 144 | else Text("The habitat could not open.", color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 145 | } |
| 146 | ui.scene?.let { scene -> |
| 147 | scene.activity?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } |
| 148 | Text("${scene.style.channel} · ${scene.style.arch}${if (scene.style.hollow) " · unobserved" else ""}", |
| 149 | Modifier.padding(horizontal = 22.dp, vertical = 10.dp), fontFamily = FontFamily.Monospace, |
| 150 | style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | @OptIn(ExperimentalLayoutApi::class) |
| 156 | @Composable |
| 157 | private fun Controls(ui: PetUiState, model: PetViewModel, modifier: Modifier) { |
| 158 | Column(modifier.padding(horizontal = 18.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { |
| 159 | FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 160 | (listOf(PetMode.SHARED, PetMode.WILD, PetMode.DEMO) + if (ui.canFollowLive) listOf(PetMode.LIVE) else emptyList()).forEach { mode -> |
| 161 | FilterChip(selected = ui.mode == mode, onClick = { model.mode(mode) }, label = { Text(mode.label) }) |
| 162 | } |
| 163 | if (ui.mode == PetMode.RECORDING) FilterChip(selected = true, onClick = {}, label = { Text("Recording") }) |
| 164 | } |
| 165 | if (ui.identity.isNotEmpty() && ui.mode == PetMode.SHARED) Text(ui.identity, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall) |
| 166 | Text(ui.mode.detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 167 | FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 168 | OutlinedButton(enabled = ui.scene != null, onClick = { model.setPaused(!ui.paused) }) { Text(if (ui.paused) "Resume" else "Pause") } |
| 169 | OutlinedButton(enabled = ui.scene != null, onClick = { model.setSound(!ui.sound) }) { Text(if (ui.mode == PetMode.SHARED) { if (ui.sound) "Companion sound on" else "Companion sound off" } else if (ui.sound) "Sound on" else "Sound off") } |
| 170 | OutlinedButton(enabled = !ui.systemStill, onClick = { model.setStill(!ui.still) }) { |
| 171 | Text(if (ui.systemStill) "System still" else if (ui.still) "Still on" else "Still off") |
| 172 | } |
| 173 | } |
| 174 | Row { |
| 175 | TextButton(onClick = { model.interact(true) }, enabled = ui.scene != null) { Text("Offer food") } |
| 176 | TextButton(onClick = { model.interact(false) }, enabled = ui.scene != null) { Text("Interact") } |
| 177 | } |
| 178 | ui.scene?.let { |
| 179 | val seconds = (it.timeMs / 1000).toLong() |
| 180 | Text("${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')} · ${it.behaviour}${if (it.peers >= 3) " · ${it.peers} pod members" else ""}", |
| 181 | style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 182 | } |
| 183 | ui.message?.let { message -> |
| 184 | Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.small) { |
| 185 | Column(Modifier.padding(12.dp)) { |
| 186 | Text(message, style = MaterialTheme.typography.bodySmall) |
| 187 | TextButton(onClick = model::dismissMessage) { Text("Dismiss") } |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 |