Files
MindMachine/app/src/main/java/com/mindmachine/mvp/MainActivity.kt

855 lines
34 KiB
Kotlin
Raw Normal View History

package com.mindmachine.mvp
import android.app.Activity
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import com.mindmachine.mvp.session.DurationSliderCard
import com.mindmachine.mvp.session.SetupTimelineEditor
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
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.WindowInsets
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.width
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.LinearProgressIndicator
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.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.input.pointer.pointerInput
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.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
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.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.data.UserProgramRepository
import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.SplitFlashFrame
import com.mindmachine.mvp.session.SplitFlashSequencer
import com.mindmachine.mvp.session.flashIntervalMsToSeconds
import com.mindmachine.mvp.session.flashIntervalSecondsToMs
import com.mindmachine.mvp.session.formatDuration
import com.mindmachine.mvp.session.sessionProgressFraction
import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault
import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
import java.util.Locale
private const val SESSION_ENTRY_HOME = "home"
private const val SESSION_ENTRY_SETUP = "setup"
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),
UserProgramRepository(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
val activity = LocalContext.current as? Activity
DisposableEffect(lifecycle, activity, ui.runtimeState) {
val observer = LifecycleEventObserver { _, event ->
val appActuallyBackgrounded = activity?.isChangingConfigurations != true
if (
event == Lifecycle.Event.ON_STOP &&
ui.runtimeState == RuntimeState.RUNNING &&
appActuallyBackgrounded
) {
vm.pause("Session paused because app moved to background.")
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
if (!ui.settingsInitialized) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)
return
}
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") {
var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) }
var confirmDeleteProgramName by remember { mutableStateOf("") }
Column(
Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.ic_app_logo),
contentDescription = "MindMachine",
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text("MindMachine")
}
},
actions = {
IconButton(
onClick = {
vm.createNewProgramDraft()
nav.navigate("setup")
}
) {
Icon(Icons.Filled.Add, contentDescription = "Add")
}
IconButton(onClick = { nav.navigate("settings") }) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
}
)
LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") }
}
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)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(p.name, style = MaterialTheme.typography.titleMedium)
Text(p.description)
Text("${formatDuration(p.defaultDurationSec)}${p.visualPatternType} • binaural ${formatMaxTwoDecimals(p.binauralDifferenceHz)}")
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(
onClick = {
val started = vm.startSessionFromProgramSelection(p.id)
if (started) {
nav.navigate("active/$SESSION_ENTRY_HOME")
}
},
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play ${p.name}",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(38.dp)
)
}
Box(
modifier = Modifier
.size(24.dp)
.clickable {
confirmDeleteProgramId = p.id
confirmDeleteProgramName = p.name
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Delete ${p.name}",
tint = Color(0xFFFF5252),
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
}
}
if (confirmDeleteProgramId != null) {
AlertDialog(
onDismissRequest = {
confirmDeleteProgramId = null
confirmDeleteProgramName = ""
},
title = { Text("Delete program?") },
text = { Text("Delete \"$confirmDeleteProgramName\"?") },
confirmButton = {
TextButton(onClick = {
confirmDeleteProgramId?.let(vm::deleteProgram)
confirmDeleteProgramId = null
confirmDeleteProgramName = ""
}) {
Text("Delete", color = Color(0xFFFF5252))
}
},
dismissButton = {
TextButton(onClick = {
confirmDeleteProgramId = null
confirmDeleteProgramName = ""
}) { Text("Cancel") }
}
)
}
}
composable("setup") {
SetupScreen(
vm = vm,
onStart = {
val started = vm.startSession()
if (started) {
nav.navigate("active/$SESSION_ENTRY_SETUP")
}
},
)
}
composable(
route = "active/{entryPoint}",
arguments = listOf(navArgument("entryPoint") { type = NavType.StringType })
) { backStackEntry ->
val entryPoint = backStackEntry.arguments?.getString("entryPoint") ?: SESSION_ENTRY_SETUP
ActiveSessionScreen(
vm = vm,
onFinish = {
nav.navigate(entryPoint) {
popUpTo("active/$entryPoint") { inclusive = true }
launchSingleTop = true
}
},
onBackToEntry = {
vm.stop()
nav.navigate(entryPoint) {
popUpTo("active/$entryPoint") { inclusive = true }
launchSingleTop = true
}
}
)
}
composable("settings") {
SettingsScreen(vm)
}
composable("holder") {
PositioningScreen()
}
}
}
@Composable
fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.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)
.safeDrawingPadding()
.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\nFor immersive visual sessions, keep your eyes closed. Do not stare at the screen.\n\nDo not place the phone directly on your eyes. Avoid pressure on face/eyes and ensure comfortable breathing/airflow.\n\nUse at your own risk. This app is a prototype and not a medical device.\n\nStop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.\n\nBinaural mode requires stereo headphones.",
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("Positioning Guidance") }
}
}
@Composable
fun SetupScreen(
vm: MainViewModel,
onStart: () -> Unit,
) {
val ui by vm.ui.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
val containerModifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.statusBars)
.windowInsetsPadding(WindowInsets.navigationBars)
.then(if (setupScreenUsesScrollableContainer()) Modifier.verticalScroll(scrollState) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp)
var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) }
val hasUnsavedChanges = setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges = ui.hasUnsavedChanges,
editedProgramName = programName,
savedProgramName = ui.savedProgramName,
)
Column(
modifier = containerModifier,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
ui.selectedPreset.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
)
TextField(
value = programName,
onValueChange = { programName = it },
label = { Text("Program name") },
modifier = Modifier.fillMaxWidth(),
)
DurationSliderCard(
durationSec = ui.timeline.durationSec,
onDurationSecChanged = { vm.setDurationSec(it) },
)
// Integrated timeline editor (no separate screen).
SetupTimelineEditor(
state = ui.timeline,
onStateChanged = vm::updateTimeline,
onCurveGranularityChanged = vm::setCurveGranularitySec,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (hasUnsavedChanges) {
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
Text("Save")
}
} else {
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") }
}
}
if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
}
internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String =
"Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s"
internal fun formatMaxTwoDecimals(value: Float): String =
String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')
internal fun setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges: Boolean,
editedProgramName: String,
savedProgramName: String,
): Boolean {
val nameChanged = editedProgramName.trim() != savedProgramName.trim()
return hasUnsavedTimelineChanges || nameChanged
}
internal fun setupScreenUsesScrollableContainer(): Boolean = true
@Composable
fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
if (ui.runtimeState == RuntimeState.COMPLETED) onFinish()
BackHandler {
2026-04-09 20:40:46 -05:00
onBackToEntry()
}
val isVisualMode = true
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
}
}
}
val immersiveFullscreen = shouldUseImmersiveFullscreen(isVisualMode, ui.runtimeState)
var revealSystemBarsSignal by remember { mutableIntStateOf(0) }
ApplyImmersiveMode(
enabled = immersiveFullscreen,
revealSignal = revealSystemBarsSignal,
)
var showOverlay by remember(ui.runtimeState) { mutableStateOf(shouldShowActiveControlsByDefault(ui.runtimeState)) }
LaunchedEffect(ui.runtimeState) {
showOverlay = shouldShowActiveControlsByDefault(ui.runtimeState)
}
var brightnessOverlayVisible by remember { mutableStateOf(false) }
var brightnessOverlayToken by remember { mutableIntStateOf(0) }
LaunchedEffect(brightnessOverlayToken) {
if (!brightnessOverlayVisible) return@LaunchedEffect
delay(2500)
brightnessOverlayVisible = false
}
ApplyKeepScreenOn(enabled = true)
ApplyScreenBrightnessOverride(
enabled = true,
brightnessPercent = ui.settings.immersiveBrightnessPercent,
)
val flashFrame by rememberSplitFlashFrame(
isRunning = ui.runtimeState == RuntimeState.RUNNING,
flashOnMs = ui.runtimeParams.flashOnMs,
flashOffMs = ui.runtimeParams.flashOffMs,
)
val progress = sessionProgressFraction(
durationSec = ui.timeline.durationSec,
remainingSec = ui.remainingSec,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.then(if (immersiveFullscreen) Modifier else Modifier.safeDrawingPadding())
.pointerInput(immersiveFullscreen) {
detectTapGestures {
brightnessOverlayVisible = true
brightnessOverlayToken++
if (!immersiveFullscreen) {
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 (immersiveFullscreen && ui.settings.showImmersiveProgressBar) {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(5.dp),
trackColor = Color.White.copy(alpha = 0.2f),
color = Color.White,
)
}
if (brightnessOverlayVisible) {
Column(
Modifier
.align(Alignment.TopCenter)
.padding(top = 24.dp)
.fillMaxWidth(0.45f)
.background(Color.Black.copy(alpha = 0.72f))
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Text(
text = "Brightness ${ui.settings.immersiveBrightnessPercent}%",
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
Slider(
value = ui.settings.immersiveBrightnessPercent.toFloat(),
onValueChange = {
vm.updateImmersiveBrightnessPercent(it.roundToInt())
brightnessOverlayVisible = true
brightnessOverlayToken++
},
valueRange = 5f..100f,
)
}
}
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 = {
2026-04-09 20:40:46 -05:00
vm.stop()
onFinish()
},
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")
}
}
}
}
}
}
}
@Composable
private fun ApplyKeepScreenOn(enabled: Boolean) {
val activity = LocalContext.current as? Activity ?: return
val window = activity.window
DisposableEffect(window, enabled) {
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
onDispose {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
@Composable
private fun ApplyScreenBrightnessOverride(enabled: Boolean, brightnessPercent: Int) {
val activity = LocalContext.current as? Activity ?: return
val window = activity.window
DisposableEffect(window) {
onDispose {
val reset = window.attributes
reset.screenBrightness = -1f
window.attributes = reset
}
}
LaunchedEffect(enabled, brightnessPercent) {
val params = window.attributes
params.screenBrightness = if (enabled) {
(brightnessPercent.coerceIn(5, 100) / 100f)
} else {
-1f
}
window.attributes = params
}
}
@Composable
private fun ApplyImmersiveMode(enabled: Boolean, revealSignal: Int) {
val context = LocalContext.current
val activity = context as? Activity ?: return
val window = activity.window
val controller = remember(window) { WindowInsetsControllerCompat(window, window.decorView) }
DisposableEffect(window) {
onDispose {
controller.show(WindowInsetsCompat.Type.systemBars())
WindowCompat.setDecorFitsSystemWindows(window, true)
}
}
LaunchedEffect(enabled) {
if (enabled) {
WindowCompat.setDecorFitsSystemWindows(window, false)
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
} else {
controller.show(WindowInsetsCompat.Type.systemBars())
WindowCompat.setDecorFitsSystemWindows(window, true)
}
}
LaunchedEffect(revealSignal, enabled) {
if (!enabled || revealSignal == 0) return@LaunchedEffect
controller.show(WindowInsetsCompat.Type.systemBars())
delay(2500)
if (enabled) {
controller.hide(WindowInsetsCompat.Type.systemBars())
}
}
}
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,
flashOnMs: Int,
flashOffMs: Int,
): androidx.compose.runtime.State<SplitFlashFrame> {
val latestFlashOnMs = rememberUpdatedState(flashOnMs)
val latestFlashOffMs = rememberUpdatedState(flashOffMs)
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning) {
if (!isRunning) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState
}
val sequencer = SplitFlashSequencer(
startTimeNanos = System.nanoTime(),
flashOnMs = latestFlashOnMs.value,
flashOffMs = latestFlashOffMs.value,
)
while (true) {
withFrameNanos { frameTimeNanos ->
sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value)
value = sequencer.frameAt(frameTimeNanos)
}
}
}
}
@Composable
fun SettingsScreen(vm: MainViewModel) {
val ui by vm.ui.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text("Countdown: ${ui.settings.countdownSeconds}s", color = MaterialTheme.colorScheme.onBackground)
Slider(
value = ui.settings.countdownSeconds.toFloat(),
onValueChange = { vm.updateCountdownSeconds(it.roundToInt()) },
valueRange = 0f..10f,
steps = 9,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = ui.settings.forceAudioWithoutHeadphones,
onCheckedChange = vm::updateForceAudioWithoutHeadphones,
)
Text("force audio without headphones", color = MaterialTheme.colorScheme.onBackground)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ui.settings.showImmersiveProgressBar, onCheckedChange = vm::updateShowImmersiveProgressBar)
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
}
HorizontalDivider()
Text(
"About/Disclaimer: MindMachine is a prototype and not a medical device. Use at your own risk. For immersive visual sessions, keep your eyes closed.",
color = MaterialTheme.colorScheme.onBackground
)
}
}
@Composable
fun PositioningScreen() {
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Positioning Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text("• Lie on your back in a safe, comfortable place.", color = MaterialTheme.colorScheme.onBackground)
Text("• Rest the phone across the upper nose/forehead area so it stays stable.", color = MaterialTheme.colorScheme.onBackground)
Text("• Keep your eyes closed during immersive visual sessions.", color = MaterialTheme.colorScheme.onBackground)
Text("• Do not rest the phone on your eyes or put pressure on your face.", color = MaterialTheme.colorScheme.onBackground)
Text("• Ensure comfortable breathing/airflow and stop if you feel discomfort.", color = MaterialTheme.colorScheme.onBackground)
Spacer(Modifier.height(12.dp))
Text("Disclaimer", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
Text(
"MindMachine is provided \"as is\" for informational and experimental purposes only, without warranties of any kind. You assume all risks of use. MindMachine is not a medical device and does not provide medical advice. To the maximum extent permitted by law, the developers and distributors disclaim all liability for any claims, damages, losses, or injuries arising from or related to use of the app.",
color = MaterialTheme.colorScheme.onBackground
)
}
}