返回 CodeWhale
PetActivity.kt
根目录 / pet / android / src / main / kotlin / codewhale / pet / PetActivity.kt
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
193 lines Plain Text