From fdf3bf01d3a06ac6a2f4b0114d0c97edfa8c58f2 Mon Sep 17 00:00:00 2001 From: Tretzi Date: Tue, 10 Mar 2026 23:14:33 -0500 Subject: [PATCH] MindMachine: improve contrast and fix visual flash/pulse rendering --- .../java/com/mindmachine/mvp/MainActivity.kt | 172 +++++++++++++----- .../mindmachine/mvp/session/VisualSignal.kt | 30 +++ .../com/mindmachine/mvp/VisualSignalTest.kt | 67 +++++++ 3 files changed, 223 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt create mode 100644 app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 22e17b6..d0a752a 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -20,6 +20,7 @@ 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 @@ -32,11 +33,15 @@ 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.getValue +import androidx.compose.runtime.mutableFloatStateOf 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 @@ -49,21 +54,35 @@ 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.domain.VisualPattern import com.mindmachine.mvp.session.MainViewModel +import com.mindmachine.mvp.session.computeVisualAlpha 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()) @@ -71,7 +90,11 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { App(vm) } + setContent { + MaterialTheme(colorScheme = MindMachineColors) { + App(vm) + } + } } } @@ -93,7 +116,7 @@ fun App(vm: MainViewModel = viewModel()) { } val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome" - NavHost(navController = nav, startDestination = startRoute) { + 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") } @@ -118,10 +141,16 @@ fun App(vm: MainViewModel = viewModel()) { 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)) { + 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) @@ -165,11 +194,11 @@ fun App(vm: MainViewModel = viewModel()) { @Composable fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) { Column( - modifier = Modifier.fillMaxSize().padding(16.dp), + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text(title, style = MaterialTheme.typography.headlineMedium) - Text(subtitle) + Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) + Text(subtitle, color = MaterialTheme.colorScheme.onBackground) actions() } } @@ -177,13 +206,16 @@ fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnSco @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) + 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.") + 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.") + 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") } @@ -194,25 +226,28 @@ fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) { 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") + 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") + Text("Mode", color = MaterialTheme.colorScheme.onBackground) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { SessionMode.values().forEach { mode -> OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) } } } - Text("Blink ${cfg.blinkFrequencyHz} Hz") + Text("Blink ${cfg.blinkFrequencyHz} Hz", color = MaterialTheme.colorScheme.onBackground) Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f) - Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz") + 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") + 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.") + 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 = Color.Red) + if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error) Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") } } } @@ -223,12 +258,19 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { 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 + val visualAlpha by rememberVisualAlpha( + isRunning = ui.runtimeState == RuntimeState.RUNNING, + mode = ui.config.mode, + visualPattern = ui.config.visualPatternType, + blinkFrequencyHz = ui.config.blinkFrequencyHz, + intensityPercent = ui.config.intensityPercent + ) + Box( - modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = if (ui.config.mode == SessionMode.AUDIO_ONLY) 0f else flashing)) + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .background(Color.White.copy(alpha = visualAlpha)) .clickable { showOverlay = !showOverlay } ) { if (ui.runtimeState == RuntimeState.COUNTDOWN) { @@ -236,11 +278,17 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { "${ui.countdownSec}", modifier = Modifier.align(Alignment.Center), style = MaterialTheme.typography.displayLarge, - color = Color.Black + color = Color.White ) } if (showOverlay) { - Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(Color.Black.copy(alpha = 0.6f)).padding(16.dp)) { + Column( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.68f)) + .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") } @@ -261,14 +309,43 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { } } +@Composable +private fun rememberVisualAlpha( + isRunning: Boolean, + mode: SessionMode, + visualPattern: VisualPattern, + blinkFrequencyHz: Float, + intensityPercent: Int, +): androidx.compose.runtime.State { + return produceState(initialValue = 0f, isRunning, mode, visualPattern, blinkFrequencyHz, intensityPercent) { + if (!isRunning || mode == SessionMode.AUDIO_ONLY) { + value = 0f + return@produceState + } + while (true) { + withFrameNanos { frameTimeNanos -> + value = computeVisualAlpha( + visualPattern = visualPattern, + frameTimeNanos = frameTimeNanos, + blinkFrequencyHz = blinkFrequencyHz, + intensityPercent = intensityPercent + ) + } + } + } +} + @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") + 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 }) { @@ -277,7 +354,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { } } } - Text("Default mode") + 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) } @@ -285,24 +362,27 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance) - Text("Show holder guidance before session") + Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground) } HorizontalDivider() - Text("About/Disclaimer: MindMachine is a prototype and not a medical device.") + 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().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.") + 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") } } } diff --git a/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt new file mode 100644 index 0000000..13941d4 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt @@ -0,0 +1,30 @@ +package com.mindmachine.mvp.session + +import com.mindmachine.mvp.domain.VisualPattern +import kotlin.math.PI +import kotlin.math.sin + +fun computeVisualAlpha( + visualPattern: VisualPattern, + frameTimeNanos: Long, + blinkFrequencyHz: Float, + intensityPercent: Int, +): Float { + val intensity = (intensityPercent.coerceIn(0, 100) / 100f) + if (intensity <= 0f) return 0f + + val frequency = blinkFrequencyHz.coerceIn(1f, 20f).toDouble() + val timeSeconds = frameTimeNanos / 1_000_000_000.0 + + return when (visualPattern) { + VisualPattern.FLASH -> { + val phase = (timeSeconds * frequency) % 1.0 + if (phase < 0.5) intensity else 0f + } + + VisualPattern.PULSE -> { + val pulse = ((sin(2.0 * PI * frequency * timeSeconds) + 1.0) / 2.0).toFloat() + (intensity * pulse).coerceIn(0f, intensity) + } + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt new file mode 100644 index 0000000..7be7be9 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt @@ -0,0 +1,67 @@ +package com.mindmachine.mvp + +import com.mindmachine.mvp.domain.VisualPattern +import com.mindmachine.mvp.session.computeVisualAlpha +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class VisualSignalTest { + + @Test + fun flashPatternAlternatesBetweenIntensityAndOff() { + val intensity = 70 + val hz = 10f + + val on = computeVisualAlpha( + visualPattern = VisualPattern.FLASH, + frameTimeNanos = 0L, + blinkFrequencyHz = hz, + intensityPercent = intensity + ) + + val off = computeVisualAlpha( + visualPattern = VisualPattern.FLASH, + frameTimeNanos = 75_000_000L, + blinkFrequencyHz = hz, + intensityPercent = intensity + ) + + assertEquals(0.7f, on, 0.0001f) + assertEquals(0f, off, 0.0001f) + } + + @Test + fun pulsePatternStaysWithinZeroAndIntensity() { + val intensity = 60 + + val atStart = computeVisualAlpha( + visualPattern = VisualPattern.PULSE, + frameTimeNanos = 0L, + blinkFrequencyHz = 3f, + intensityPercent = intensity + ) + val quarterCycle = computeVisualAlpha( + visualPattern = VisualPattern.PULSE, + frameTimeNanos = 83_333_333L, + blinkFrequencyHz = 3f, + intensityPercent = intensity + ) + + assertTrue(atStart in 0f..0.6f) + assertTrue(quarterCycle in 0f..0.6f) + assertTrue(quarterCycle > atStart) + } + + @Test + fun intensityZeroAlwaysReturnsZero() { + val alpha = computeVisualAlpha( + visualPattern = VisualPattern.FLASH, + frameTimeNanos = 123_000_000L, + blinkFrequencyHz = 8f, + intensityPercent = 0 + ) + + assertEquals(0f, alpha, 0f) + } +}