UX: seconds flash interval UI, hidden active controls, back-to-setup flow

This commit is contained in:
Tretzi
2026-03-10 23:34:07 -05:00
parent bb5d77fea3
commit e5117d2346
7 changed files with 91 additions and 12 deletions

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}