From e5117d2346195c490cb95829a3bacf72f67505ef Mon Sep 17 00:00:00 2001 From: Tretzi Date: Tue, 10 Mar 2026 23:34:07 -0500 Subject: [PATCH] UX: seconds flash interval UI, hidden active controls, back-to-setup flow --- .../java/com/mindmachine/mvp/MainActivity.kt | 37 ++++++++++++++----- .../mvp/session/ActiveSessionUi.kt | 7 ++++ .../mvp/session/FlashIntervalUi.kt | 9 +++++ .../mvp/session/SessionValidator.kt | 2 +- .../mindmachine/mvp/ActiveSessionUiTest.kt | 24 ++++++++++++ .../mindmachine/mvp/FlashIntervalUiTest.kt | 22 +++++++++++ .../mindmachine/mvp/SessionValidatorTest.kt | 2 +- 7 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/FlashIntervalUi.kt create mode 100644 app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt create mode 100644 app/src/test/java/com/mindmachine/mvp/FlashIntervalUiTest.kt diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 6c59c25..a61d210 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -4,6 +4,7 @@ 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 @@ -41,6 +42,7 @@ 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.mutableStateOf import androidx.compose.runtime.produceState @@ -72,6 +74,9 @@ 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 com.mindmachine.mvp.session.flashIntervalMsToSeconds +import com.mindmachine.mvp.session.flashIntervalSecondsToMs +import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault import kotlin.math.roundToInt private val MindMachineColors = darkColorScheme( @@ -174,7 +179,11 @@ fun App(vm: MainViewModel = viewModel()) { }, onHolder = { nav.navigate("holder") }) } composable("active") { - ActiveSessionScreen(vm = vm, onFinish = { nav.navigate("complete") { popUpTo("setup") } }) + ActiveSessionScreen( + vm = vm, + onFinish = { nav.navigate("complete") { popUpTo("setup") } }, + onBackToSetup = { nav.popBackStack("setup", inclusive = false) } + ) } composable("complete") { SimpleScreen( @@ -250,13 +259,13 @@ fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) { ) } } - Text("Flash interval: ${cfg.flashIntervalMs} ms", color = MaterialTheme.colorScheme.onBackground) + Text("Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(cfg.flashIntervalMs))} s", color = MaterialTheme.colorScheme.onBackground) Slider( - value = cfg.flashIntervalMs.toFloat(), - onValueChange = { vm.setFlashIntervalMs(it.roundToInt()) }, - valueRange = 50f..2000f + value = flashIntervalMsToSeconds(cfg.flashIntervalMs), + onValueChange = { vm.setFlashIntervalMs(flashIntervalSecondsToMs(it)) }, + valueRange = 0.05f..2.0f ) - Text("Controls how long each frame is shown (red/green/black pattern).", color = MaterialTheme.colorScheme.onBackground) + Text("Controls how long each frame is shown in the 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) @@ -269,9 +278,14 @@ fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) { } @Composable -fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { +fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup: () -> Unit) { val ui by vm.ui.collectAsStateWithLifecycle() - if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish() + if (ui.runtimeState == RuntimeState.COMPLETED) onFinish() + + BackHandler { + vm.stop() + onBackToSetup() + } val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY val context = LocalContext.current @@ -288,7 +302,10 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { } } - var showOverlay by remember { mutableStateOf(true) } + var showOverlay by remember(ui.runtimeState) { mutableStateOf(shouldShowActiveControlsByDefault(ui.runtimeState)) } + LaunchedEffect(ui.runtimeState) { + showOverlay = shouldShowActiveControlsByDefault(ui.runtimeState) + } val flashFrame by rememberSplitFlashFrame( isRunning = ui.runtimeState == RuntimeState.RUNNING, mode = ui.config.mode, @@ -340,7 +357,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { } else { Button(onClick = { vm.resume() }, modifier = Modifier.fillMaxWidth()) { Text("Resume") } } - OutlinedButton(onClick = { vm.stop() }, modifier = Modifier.fillMaxWidth()) { Text("Stop") } + 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) { diff --git a/app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt b/app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt new file mode 100644 index 0000000..aef381b --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt @@ -0,0 +1,7 @@ +package com.mindmachine.mvp.session + +import com.mindmachine.mvp.domain.RuntimeState + +fun shouldShowActiveControlsByDefault(runtimeState: RuntimeState): Boolean { + return runtimeState != RuntimeState.RUNNING && runtimeState != RuntimeState.COUNTDOWN +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/FlashIntervalUi.kt b/app/src/main/java/com/mindmachine/mvp/session/FlashIntervalUi.kt new file mode 100644 index 0000000..1bd9d37 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/FlashIntervalUi.kt @@ -0,0 +1,9 @@ +package com.mindmachine.mvp.session + +import kotlin.math.roundToInt + +private const val MILLIS_PER_SECOND = 1000f + +fun flashIntervalMsToSeconds(ms: Int): Float = ms / MILLIS_PER_SECOND + +fun flashIntervalSecondsToMs(seconds: Float): Int = (seconds * MILLIS_PER_SECOND).roundToInt() diff --git a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt index 2cc0fe6..47985ca 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt @@ -6,7 +6,7 @@ import com.mindmachine.mvp.domain.SessionMode object SessionValidator { fun validate(config: SessionConfig, headsetAvailable: Boolean): String? { if (config.durationSec !in 60..(30 * 60)) return "Duration must be 1 to 30 minutes." - if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 50 to 2000 ms." + if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds." if (config.carrierFrequencyHz !in 80f..400f) return "Carrier frequency must be 80 to 400 Hz." if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be 0.5 to 20 Hz." if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) { diff --git a/app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt b/app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt new file mode 100644 index 0000000..cd841b9 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt @@ -0,0 +1,24 @@ +package com.mindmachine.mvp + +import com.mindmachine.mvp.domain.RuntimeState +import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ActiveSessionUiTest { + @Test + fun controls_are_hidden_by_default_while_counting_down_or_running() { + assertFalse(shouldShowActiveControlsByDefault(RuntimeState.COUNTDOWN)) + assertFalse(shouldShowActiveControlsByDefault(RuntimeState.RUNNING)) + } + + @Test + fun controls_are_visible_by_default_when_not_actively_stimulating() { + assertTrue(shouldShowActiveControlsByDefault(RuntimeState.IDLE)) + assertTrue(shouldShowActiveControlsByDefault(RuntimeState.PAUSED)) + assertTrue(shouldShowActiveControlsByDefault(RuntimeState.INTERRUPTED)) + assertTrue(shouldShowActiveControlsByDefault(RuntimeState.COMPLETED)) + assertTrue(shouldShowActiveControlsByDefault(RuntimeState.STOPPED)) + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/FlashIntervalUiTest.kt b/app/src/test/java/com/mindmachine/mvp/FlashIntervalUiTest.kt new file mode 100644 index 0000000..1585760 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/FlashIntervalUiTest.kt @@ -0,0 +1,22 @@ +package com.mindmachine.mvp + +import com.mindmachine.mvp.session.flashIntervalMsToSeconds +import com.mindmachine.mvp.session.flashIntervalSecondsToMs +import org.junit.Assert.assertEquals +import org.junit.Test + +class FlashIntervalUiTest { + @Test + fun ms_to_seconds_conversion_is_correct() { + assertEquals(0.05f, flashIntervalMsToSeconds(50), 0.0001f) + assertEquals(0.167f, flashIntervalMsToSeconds(167), 0.0001f) + assertEquals(2.0f, flashIntervalMsToSeconds(2000), 0.0001f) + } + + @Test + fun seconds_to_ms_conversion_rounds_to_nearest_int() { + assertEquals(50, flashIntervalSecondsToMs(0.05f)) + assertEquals(167, flashIntervalSecondsToMs(0.167f)) + assertEquals(2000, flashIntervalSecondsToMs(2.0f)) + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt index 4c9464c..ddb6321 100644 --- a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt @@ -39,7 +39,7 @@ class SessionValidatorTest { @Test fun out_of_range_values_are_blocked() { assertEquals("Duration must be 1 to 30 minutes.", SessionValidator.validate(valid.copy(durationSec = 59), true)) - assertEquals("Flash interval must be 50 to 2000 ms.", SessionValidator.validate(valid.copy(flashIntervalMs = 49), true)) + assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49), true)) assertEquals("Carrier frequency must be 80 to 400 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 79f), true)) assertEquals("Binaural difference must be 0.5 to 20 Hz.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f), true)) }