431 lines
19 KiB
Kotlin
431 lines
19 KiB
Kotlin
package com.mindmachine.mvp
|
|
|
|
import android.app.Activity
|
|
import android.content.pm.ActivityInfo
|
|
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.fillMaxHeight
|
|
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.CardDefaults
|
|
import androidx.compose.material3.Checkbox
|
|
import androidx.compose.material3.DropdownMenu
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
import androidx.compose.material3.HorizontalDivider
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.SegmentedButton
|
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
import androidx.compose.material3.Slider
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.material3.TopAppBar
|
|
import androidx.compose.material3.darkColorScheme
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.produceState
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.withFrameNanos
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
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.compose.NavHost
|
|
import androidx.navigation.compose.composable
|
|
import androidx.navigation.compose.rememberNavController
|
|
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.FlashColor
|
|
import com.mindmachine.mvp.session.MainViewModel
|
|
import com.mindmachine.mvp.session.SplitFlashFrame
|
|
import com.mindmachine.mvp.session.computeSplitFlashFrame
|
|
import kotlin.math.roundToInt
|
|
|
|
private val MindMachineColors = darkColorScheme(
|
|
primary = Color(0xFF8AB4FF),
|
|
onPrimary = Color(0xFF001B3D),
|
|
primaryContainer = Color(0xFF004689),
|
|
onPrimaryContainer = Color(0xFFD7E3FF),
|
|
secondary = Color(0xFFFFD166),
|
|
onSecondary = Color(0xFF2E2300),
|
|
background = Color(0xFF05070D),
|
|
onBackground = Color(0xFFF3F6FF),
|
|
surface = Color(0xFF121621),
|
|
onSurface = Color(0xFFF3F6FF),
|
|
error = Color(0xFFFF6B6B),
|
|
onError = Color(0xFF3A0002)
|
|
)
|
|
|
|
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 {
|
|
MaterialTheme(colorScheme = MindMachineColors) {
|
|
App(vm)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun App(vm: MainViewModel = viewModel()) {
|
|
val nav = rememberNavController()
|
|
val ui by vm.ui.collectAsStateWithLifecycle()
|
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
|
|
|
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, modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
|
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(
|
|
colors = CardDefaults.cardColors(
|
|
containerColor = MaterialTheme.colorScheme.surface,
|
|
contentColor = MaterialTheme.colorScheme.onSurface
|
|
),
|
|
modifier = 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().background(MaterialTheme.colorScheme.background).padding(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
) {
|
|
Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
|
|
Text(subtitle, color = MaterialTheme.colorScheme.onBackground)
|
|
actions()
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
|
|
var checked by remember { mutableStateOf(false) }
|
|
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp)) {
|
|
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
|
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.",
|
|
color = MaterialTheme.colorScheme.onBackground
|
|
)
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Checkbox(checked = checked, onCheckedChange = { checked = it })
|
|
Text("I understand the risks and will stop immediately if I feel discomfort.", color = MaterialTheme.colorScheme.onBackground)
|
|
}
|
|
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().background(MaterialTheme.colorScheme.background).padding(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
|
) {
|
|
Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
|
Text("Duration: ${cfg.durationSec / 60} min", color = MaterialTheme.colorScheme.onBackground)
|
|
Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f)
|
|
Text("Mode", color = MaterialTheme.colorScheme.onBackground)
|
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
SessionMode.values().forEachIndexed { index, mode ->
|
|
SegmentedButton(
|
|
shape = androidx.compose.material3.SegmentedButtonDefaults.itemShape(index = index, count = SessionMode.values().size),
|
|
selected = cfg.mode == mode,
|
|
onClick = { vm.setMode(mode) },
|
|
label = { Text(mode.name.replace("_", " ")) }
|
|
)
|
|
}
|
|
}
|
|
Text("Flash interval: ${cfg.flashIntervalMs} ms", color = MaterialTheme.colorScheme.onBackground)
|
|
Slider(
|
|
value = cfg.flashIntervalMs.toFloat(),
|
|
onValueChange = { vm.setFlashIntervalMs(it.roundToInt()) },
|
|
valueRange = 50f..2000f
|
|
)
|
|
Text("Controls how long each frame is shown (red/green/black pattern).", color = MaterialTheme.colorScheme.onBackground)
|
|
Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz", color = MaterialTheme.colorScheme.onBackground)
|
|
Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f)
|
|
Text("Difference ${cfg.binauralDifferenceHz} Hz", color = MaterialTheme.colorScheme.onBackground)
|
|
Slider(value = cfg.binauralDifferenceHz, onValueChange = vm::setDifference, valueRange = 0.5f..20f)
|
|
Text("Brightness recommendation: keep screen comfortable and avoid eye strain.", color = MaterialTheme.colorScheme.onBackground)
|
|
TextButton(onClick = onHolder) { Text("Holder Guidance") }
|
|
if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error)
|
|
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()
|
|
|
|
val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY
|
|
val context = LocalContext.current
|
|
DisposableEffect(isVisualMode) {
|
|
val activity = context as? Activity
|
|
val previous = activity?.requestedOrientation
|
|
if (isVisualMode) {
|
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
|
}
|
|
onDispose {
|
|
if (isVisualMode) {
|
|
activity?.requestedOrientation = previous ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
|
}
|
|
}
|
|
}
|
|
|
|
var showOverlay by remember { mutableStateOf(true) }
|
|
val flashFrame by rememberSplitFlashFrame(
|
|
isRunning = ui.runtimeState == RuntimeState.RUNNING,
|
|
mode = ui.config.mode,
|
|
flashIntervalMs = ui.config.flashIntervalMs,
|
|
)
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(Color.Black)
|
|
.clickable { showOverlay = !showOverlay }
|
|
) {
|
|
if (isVisualMode) {
|
|
Row(Modifier.fillMaxSize()) {
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.fillMaxHeight()
|
|
.background(flashFrame.left.toComposeColor())
|
|
)
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.fillMaxHeight()
|
|
.background(flashFrame.right.toComposeColor())
|
|
)
|
|
}
|
|
}
|
|
|
|
if (ui.runtimeState == RuntimeState.COUNTDOWN) {
|
|
Text(
|
|
"${ui.countdownSec}",
|
|
modifier = Modifier.align(Alignment.Center),
|
|
style = MaterialTheme.typography.displayLarge,
|
|
color = Color.White
|
|
)
|
|
}
|
|
if (showOverlay) {
|
|
Column(
|
|
Modifier
|
|
.align(Alignment.BottomCenter)
|
|
.fillMaxWidth()
|
|
.background(Color.Black.copy(alpha = 0.72f))
|
|
.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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun FlashColor.toComposeColor(): Color = when (this) {
|
|
FlashColor.RED -> Color.Red
|
|
FlashColor.GREEN -> Color.Green
|
|
FlashColor.BLACK -> Color.Black
|
|
}
|
|
|
|
@Composable
|
|
private fun rememberSplitFlashFrame(
|
|
isRunning: Boolean,
|
|
mode: SessionMode,
|
|
flashIntervalMs: Int,
|
|
) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashIntervalMs) {
|
|
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
|
|
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
|
|
return@produceState
|
|
}
|
|
while (true) {
|
|
withFrameNanos { frameTimeNanos ->
|
|
value = computeSplitFlashFrame(frameTimeNanos, flashIntervalMs)
|
|
}
|
|
}
|
|
}
|
|
|
|
@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().background(MaterialTheme.colorScheme.background).padding(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
|
Text("Countdown", color = MaterialTheme.colorScheme.onBackground)
|
|
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
|
TextButton(onClick = { expanded = true }) { Text(ui.settings.countdownPreference.name) }
|
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
|
CountdownPreference.values().forEach {
|
|
DropdownMenuItem(text = { Text(it.name) }, onClick = { vm.updateCountdown(it); expanded = false })
|
|
}
|
|
}
|
|
}
|
|
Text("Default mode", color = MaterialTheme.colorScheme.onBackground)
|
|
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", color = MaterialTheme.colorScheme.onBackground)
|
|
}
|
|
HorizontalDivider()
|
|
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", color = MaterialTheme.colorScheme.onBackground)
|
|
OutlinedButton(onClick = onBack) { Text("Back") }
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun HolderScreen(onBack: () -> Unit) {
|
|
Column(
|
|
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
|
Text("• Use a simple cardboard visor/holder.", color = MaterialTheme.colorScheme.onBackground)
|
|
Text("• Keep phone stable and hands-free.", color = MaterialTheme.colorScheme.onBackground)
|
|
Text("• Do not press device against eyes/face.", color = MaterialTheme.colorScheme.onBackground)
|
|
Text("• Allow airflow and comfort.", color = MaterialTheme.colorScheme.onBackground)
|
|
Text("• Test fit before session.", color = MaterialTheme.colorScheme.onBackground)
|
|
Text("• Sit or lie down in a safe place.", color = MaterialTheme.colorScheme.onBackground)
|
|
OutlinedButton(onClick = onBack) { Text("Back") }
|
|
}
|
|
}
|