UX: seconds flash interval UI, hidden active controls, back-to-setup flow
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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) {
|
||||
|
||||
24
app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt
Normal file
24
app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
22
app/src/test/java/com/mindmachine/mvp/FlashIntervalUiTest.kt
Normal file
22
app/src/test/java/com/mindmachine/mvp/FlashIntervalUiTest.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user