feat(mindmachine): implement Android MVP app scaffold, session flows, safety gating, and tests
This commit is contained in:
307
app/src/main/java/com/mindmachine/mvp/MainActivity.kt
Normal file
307
app/src/main/java/com/mindmachine/mvp/MainActivity.kt
Normal file
@@ -0,0 +1,307 @@
|
||||
package com.mindmachine.mvp
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenu
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.mindmachine.mvp.audio.BinauralAudioEngine
|
||||
import com.mindmachine.mvp.audio.HeadsetMonitor
|
||||
import com.mindmachine.mvp.data.SettingsRepository
|
||||
import com.mindmachine.mvp.domain.CountdownPreference
|
||||
import com.mindmachine.mvp.domain.RuntimeState
|
||||
import com.mindmachine.mvp.domain.SessionMode
|
||||
import com.mindmachine.mvp.session.MainViewModel
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val vm: MainViewModel by viewModels {
|
||||
MainViewModel.Factory(SettingsRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { App(vm) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App(vm: MainViewModel = viewModel()) {
|
||||
val nav = rememberNavController()
|
||||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
|
||||
androidx.compose.runtime.DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP && ui.runtimeState == RuntimeState.RUNNING) {
|
||||
vm.pause("Session paused because app moved to background.")
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome"
|
||||
NavHost(navController = nav, startDestination = startRoute) {
|
||||
composable("welcome") {
|
||||
SimpleScreen("MindMachine", "Blinking light + binaural audio. Stereo headphones required for binaural mode. Not a medical device.") {
|
||||
Button(onClick = { nav.navigate("safety") }) { Text("Continue") }
|
||||
}
|
||||
}
|
||||
composable("safety") {
|
||||
SafetyScreen(
|
||||
onAck = {
|
||||
vm.acknowledgeSafety()
|
||||
nav.navigate("home") { popUpTo(0) }
|
||||
},
|
||||
onHolder = { nav.navigate("holder") }
|
||||
)
|
||||
}
|
||||
composable("home") {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(title = { Text("MindMachine") }, actions = {
|
||||
TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") }
|
||||
})
|
||||
LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
TextButton(onClick = { nav.navigate("holder") }) { Text("Holder Guidance") }
|
||||
}
|
||||
items(ui.presets) { p ->
|
||||
Card(Modifier.fillMaxWidth().clickable {
|
||||
vm.choosePreset(p.id)
|
||||
nav.navigate("setup")
|
||||
}.padding(4.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(p.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(p.description)
|
||||
Text("${p.defaultDurationSec / 60} min • ${p.visualPatternType} • ${p.binauralDifferenceHz} Hz")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composable("setup") {
|
||||
SetupScreen(vm = vm, onStart = {
|
||||
vm.startSession()
|
||||
nav.navigate("active")
|
||||
}, onHolder = { nav.navigate("holder") })
|
||||
}
|
||||
composable("active") {
|
||||
ActiveSessionScreen(vm = vm, onFinish = { nav.navigate("complete") { popUpTo("setup") } })
|
||||
}
|
||||
composable("complete") {
|
||||
SimpleScreen(
|
||||
if (ui.endedEarly) "Session ended" else "Session complete.",
|
||||
ui.selectedPreset.name
|
||||
) {
|
||||
Button(onClick = {
|
||||
vm.startSession()
|
||||
nav.navigate("active")
|
||||
}) { Text("Repeat Session") }
|
||||
OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") }
|
||||
}
|
||||
}
|
||||
composable("settings") {
|
||||
SettingsScreen(vm) { nav.popBackStack() }
|
||||
}
|
||||
composable("holder") {
|
||||
HolderScreen { nav.popBackStack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium)
|
||||
Text(subtitle)
|
||||
actions()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
|
||||
var checked by remember { mutableStateOf(false) }
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.\n\nDo not use while driving, walking, cycling, or operating machinery.\n\nStop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.\n\nBinaural mode requires stereo headphones.\n\nThis app is not a medical device.")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = checked, onCheckedChange = { checked = it })
|
||||
Text("I understand the risks and will stop immediately if I feel discomfort.")
|
||||
}
|
||||
Button(onClick = onAck, enabled = checked, modifier = Modifier.semantics { contentDescription = "I Understand" }) { Text("I Understand") }
|
||||
TextButton(onClick = onHolder) { Text("Holder Guidance") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) {
|
||||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||||
val cfg = ui.config
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Duration: ${cfg.durationSec / 60} min")
|
||||
Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f)
|
||||
Text("Mode")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SessionMode.values().forEach { mode ->
|
||||
OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) }
|
||||
}
|
||||
}
|
||||
Text("Blink ${cfg.blinkFrequencyHz} Hz")
|
||||
Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f)
|
||||
Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz")
|
||||
Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f)
|
||||
Text("Difference ${cfg.binauralDifferenceHz} Hz")
|
||||
Slider(value = cfg.binauralDifferenceHz, onValueChange = vm::setDifference, valueRange = 0.5f..20f)
|
||||
Text("Brightness recommendation: keep screen comfortable and avoid eye strain.")
|
||||
TextButton(onClick = onHolder) { Text("Holder Guidance") }
|
||||
if (ui.error != null) Text(ui.error!!, color = Color.Red)
|
||||
Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
|
||||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||||
if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish()
|
||||
|
||||
var showOverlay by remember { mutableStateOf(true) }
|
||||
val intensity = (ui.config.intensityPercent / 100f)
|
||||
val flashing = if (ui.config.visualPatternType.name == "FLASH") {
|
||||
if ((ui.remainingSec % 2) == 0) intensity else 0f
|
||||
} else intensity * 0.5f
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = if (ui.config.mode == SessionMode.AUDIO_ONLY) 0f else flashing))
|
||||
.clickable { showOverlay = !showOverlay }
|
||||
) {
|
||||
if (ui.runtimeState == RuntimeState.COUNTDOWN) {
|
||||
Text(
|
||||
"${ui.countdownSec}",
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
if (showOverlay) {
|
||||
Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(Color.Black.copy(alpha = 0.6f)).padding(16.dp)) {
|
||||
Text("${ui.selectedPreset.name} • ${ui.remainingSec}s", color = Color.White)
|
||||
if (ui.runtimeState == RuntimeState.RUNNING) {
|
||||
Button(onClick = { vm.pause() }, modifier = Modifier.fillMaxWidth()) { Text("Pause") }
|
||||
} else {
|
||||
Button(onClick = { vm.resume() }, modifier = Modifier.fillMaxWidth()) { Text("Resume") }
|
||||
}
|
||||
OutlinedButton(onClick = { vm.stop() }, modifier = Modifier.fillMaxWidth()) { Text("Stop") }
|
||||
if (ui.interruptionReason != null) {
|
||||
Text(ui.interruptionReason!!, color = Color.White)
|
||||
if (ui.runtimeState == RuntimeState.INTERRUPTED) {
|
||||
OutlinedButton(onClick = { vm.switchToVisualOnlyAndResume() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Resume Visual-only")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Countdown")
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
TextButton(onClick = { expanded = true }) { Text(ui.settings.countdownPreference.name) }
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
CountdownPreference.values().forEach {
|
||||
DropdownMenuItem(text = { Text(it.name) }, onClick = { vm.updateCountdown(it); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Default mode")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SessionMode.values().forEach { mode ->
|
||||
OutlinedButton(onClick = { vm.updateDefaultMode(mode) }) { Text(mode.name) }
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance)
|
||||
Text("Show holder guidance before session")
|
||||
}
|
||||
HorizontalDivider()
|
||||
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.")
|
||||
OutlinedButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HolderScreen(onBack: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("• Use a simple cardboard visor/holder.")
|
||||
Text("• Keep phone stable and hands-free.")
|
||||
Text("• Do not press device against eyes/face.")
|
||||
Text("• Allow airflow and comfort.")
|
||||
Text("• Test fit before session.")
|
||||
Text("• Sit or lie down in a safe place.")
|
||||
OutlinedButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user