787 lines
31 KiB
Kotlin
787 lines
31 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.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.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.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.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.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.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 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 = { 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)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.PlayArrow,
|
|
contentDescription = null,
|
|
tint = Color(0xFF4CAF50),
|
|
modifier = Modifier.size(30.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")
|
|
}
|
|
},
|
|
)
|
|
}
|
|
composable("active") {
|
|
ActiveSessionScreen(
|
|
vm = vm,
|
|
onFinish = { nav.navigate("complete") { popUpTo("setup") } },
|
|
onBackToSetup = { nav.popBackStack("setup", inclusive = false) }
|
|
)
|
|
}
|
|
composable("complete") {
|
|
SimpleScreen(
|
|
if (ui.endedEarly) "Session ended" else "Session complete.",
|
|
ui.selectedPreset.name
|
|
) {
|
|
Button(onClick = {
|
|
val started = vm.startSession()
|
|
if (started) {
|
|
nav.navigate("active")
|
|
}
|
|
}) { Text("Repeat Session") }
|
|
OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") }
|
|
}
|
|
}
|
|
composable("settings") {
|
|
SettingsScreen(vm)
|
|
}
|
|
composable("holder") {
|
|
HolderScreen()
|
|
}
|
|
}
|
|
}
|
|
|
|
@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\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,
|
|
) {
|
|
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, onBackToSetup: () -> Unit) {
|
|
val ui by vm.ui.collectAsStateWithLifecycle()
|
|
if (ui.runtimeState == RuntimeState.COMPLETED) onFinish()
|
|
|
|
BackHandler {
|
|
vm.stop()
|
|
onBackToSetup()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 = { 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 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.", color = MaterialTheme.colorScheme.onBackground)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun HolderScreen() {
|
|
Column(
|
|
Modifier
|
|
.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.background)
|
|
.safeDrawingPadding()
|
|
.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)
|
|
}
|
|
}
|