Auto commit Sun Mar 22 01:01:14 AM CDT 2026

This commit is contained in:
Tretzi
2026-03-22 01:01:14 -05:00
parent 88723bf214
commit ab5f4d5acf
7 changed files with 200 additions and 92 deletions

View File

@@ -79,7 +79,6 @@ import com.mindmachine.mvp.audio.HeadsetMonitor
import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.data.UserProgramRepository import com.mindmachine.mvp.data.UserProgramRepository
import com.mindmachine.mvp.domain.RuntimeState import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.domain.SessionMode
import com.mindmachine.mvp.session.FlashColor import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.MainViewModel import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.SplitFlashFrame import com.mindmachine.mvp.session.SplitFlashFrame
@@ -144,6 +143,15 @@ fun App(vm: MainViewModel = viewModel()) {
onDispose { lifecycle.removeObserver(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" val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome"
NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) { NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
composable("welcome") { composable("welcome") {
@@ -235,10 +243,10 @@ fun App(vm: MainViewModel = viewModel()) {
} }
} }
composable("settings") { composable("settings") {
SettingsScreen(vm) { nav.popBackStack() } SettingsScreen(vm)
} }
composable("holder") { composable("holder") {
HolderScreen { nav.popBackStack() } HolderScreen()
} }
} }
} }
@@ -281,7 +289,6 @@ fun SetupScreen(
onHolder: () -> Unit, onHolder: () -> Unit,
) { ) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val cfg = ui.config
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val containerModifier = Modifier val containerModifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -292,6 +299,12 @@ fun SetupScreen(
var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) } var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) }
val hasUnsavedChanges = setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges = ui.hasUnsavedChanges,
editedProgramName = programName,
savedProgramName = ui.savedProgramName,
)
Column( Column(
modifier = containerModifier, modifier = containerModifier,
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -323,10 +336,13 @@ fun SetupScreen(
TextButton(onClick = onHolder) { Text("Holder Guidance") } TextButton(onClick = onHolder) { Text("Holder Guidance") }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) { if (hasUnsavedChanges) {
Text("Save") 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") }
} }
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) if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -336,6 +352,15 @@ fun SetupScreen(
internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String = internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String =
"Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s" "Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s"
internal fun setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges: Boolean,
editedProgramName: String,
savedProgramName: String,
): Boolean {
val nameChanged = editedProgramName.trim() != savedProgramName.trim()
return hasUnsavedTimelineChanges || nameChanged
}
internal fun setupScreenUsesScrollableContainer(): Boolean = true internal fun setupScreenUsesScrollableContainer(): Boolean = true
@Composable @Composable
@@ -348,7 +373,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
onBackToSetup() onBackToSetup()
} }
val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY val isVisualMode = true
val context = LocalContext.current val context = LocalContext.current
DisposableEffect(isVisualMode) { DisposableEffect(isVisualMode) {
val activity = context as? Activity val activity = context as? Activity
@@ -376,7 +401,6 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
} }
val flashFrame by rememberSplitFlashFrame( val flashFrame by rememberSplitFlashFrame(
isRunning = ui.runtimeState == RuntimeState.RUNNING, isRunning = ui.runtimeState == RuntimeState.RUNNING,
mode = ui.config.mode,
flashOnMs = ui.runtimeParams.flashOnMs, flashOnMs = ui.runtimeParams.flashOnMs,
flashOffMs = ui.runtimeParams.flashOffMs, flashOffMs = ui.runtimeParams.flashOffMs,
) )
@@ -506,15 +530,14 @@ private fun FlashColor.toComposeColor(): Color = when (this) {
@Composable @Composable
private fun rememberSplitFlashFrame( private fun rememberSplitFlashFrame(
isRunning: Boolean, isRunning: Boolean,
mode: SessionMode,
flashOnMs: Int, flashOnMs: Int,
flashOffMs: Int, flashOffMs: Int,
): androidx.compose.runtime.State<SplitFlashFrame> { ): androidx.compose.runtime.State<SplitFlashFrame> {
val latestFlashOnMs = rememberUpdatedState(flashOnMs) val latestFlashOnMs = rememberUpdatedState(flashOnMs)
val latestFlashOffMs = rememberUpdatedState(flashOffMs) val latestFlashOffMs = rememberUpdatedState(flashOffMs)
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode) { return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) { if (!isRunning) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState return@produceState
} }
@@ -535,7 +558,7 @@ private fun rememberSplitFlashFrame(
} }
@Composable @Composable
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { fun SettingsScreen(vm: MainViewModel) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
Column( Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
@@ -551,16 +574,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
steps = 9, steps = 9,
) )
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) }
}
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance) Checkbox(
Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground) checked = ui.settings.forceAudioWithoutHeadphones,
onCheckedChange = vm::updateForceAudioWithoutHeadphones,
)
Text("force audio without headphones", color = MaterialTheme.colorScheme.onBackground)
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -570,12 +589,11 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
HorizontalDivider() HorizontalDivider()
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", color = MaterialTheme.colorScheme.onBackground) Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", color = MaterialTheme.colorScheme.onBackground)
OutlinedButton(onClick = onBack) { Text("Back") }
} }
} }
@Composable @Composable
fun HolderScreen(onBack: () -> Unit) { fun HolderScreen() {
Column( Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -587,6 +605,5 @@ fun HolderScreen(onBack: () -> Unit) {
Text("• Allow airflow and comfort.", color = MaterialTheme.colorScheme.onBackground) Text("• Allow airflow and comfort.", color = MaterialTheme.colorScheme.onBackground)
Text("• Test fit before session.", 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) Text("• Sit or lie down in a safe place.", color = MaterialTheme.colorScheme.onBackground)
OutlinedButton(onClick = onBack) { Text("Back") }
} }
} }

View File

@@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.mindmachine.mvp.domain.AppSettings import com.mindmachine.mvp.domain.AppSettings
import com.mindmachine.mvp.domain.SessionMode
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -19,8 +18,7 @@ class SettingsRepository(private val context: Context) {
val safetyVersion = intPreferencesKey("safety_version") val safetyVersion = intPreferencesKey("safety_version")
val countdownPref = stringPreferencesKey("countdown_pref") val countdownPref = stringPreferencesKey("countdown_pref")
val countdownSeconds = intPreferencesKey("countdown_seconds") val countdownSeconds = intPreferencesKey("countdown_seconds")
val defaultMode = stringPreferencesKey("default_mode") val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones")
val showGuidance = booleanPreferencesKey("show_guidance")
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar") val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
val lastPresetId = stringPreferencesKey("last_preset") val lastPresetId = stringPreferencesKey("last_preset")
} }
@@ -32,8 +30,7 @@ class SettingsRepository(private val context: Context) {
countdownSeconds = p[Keys.countdownSeconds] countdownSeconds = p[Keys.countdownSeconds]
?: legacyCountdownStringToSeconds(p[Keys.countdownPref]) ?: legacyCountdownStringToSeconds(p[Keys.countdownPref])
?: 5, ?: 5,
defaultModePreference = runCatching { SessionMode.valueOf(p[Keys.defaultMode] ?: "AUDIO_VISUAL") }.getOrDefault(SessionMode.AUDIO_VISUAL), forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
showHolderGuidanceBeforeSession = p[Keys.showGuidance] ?: false,
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true, showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
lastPresetId = p[Keys.lastPresetId], lastPresetId = p[Keys.lastPresetId],
) )
@@ -50,9 +47,14 @@ class SettingsRepository(private val context: Context) {
it[Keys.countdownSeconds] = value.coerceIn(0, 10) it[Keys.countdownSeconds] = value.coerceIn(0, 10)
} }
suspend fun updateDefaultMode(value: SessionMode) = context.dataStore.edit { it[Keys.defaultMode] = value.name } suspend fun updateForceAudioWithoutHeadphones(value: Boolean) = context.dataStore.edit {
suspend fun updateGuidance(value: Boolean) = context.dataStore.edit { it[Keys.showGuidance] = value } it[Keys.forceAudioWithoutHeadphones] = value
suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit { it[Keys.showImmersiveProgressBar] = value } }
suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit {
it[Keys.showImmersiveProgressBar] = value
}
suspend fun updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id } suspend fun updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id }
private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) { private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) {

View File

@@ -34,8 +34,7 @@ data class AppSettings(
val safetyAcknowledged: Boolean = false, val safetyAcknowledged: Boolean = false,
val safetyAcknowledgedVersion: Int = 0, val safetyAcknowledgedVersion: Int = 0,
val countdownSeconds: Int = 5, val countdownSeconds: Int = 5,
val defaultModePreference: SessionMode = SessionMode.AUDIO_VISUAL, val forceAudioWithoutHeadphones: Boolean = false,
val showHolderGuidanceBeforeSession: Boolean = false,
val showImmersiveProgressBar: Boolean = true, val showImmersiveProgressBar: Boolean = true,
val lastPresetId: String? = null, val lastPresetId: String? = null,
) )

View File

@@ -28,6 +28,7 @@ import java.util.UUID
const val SAFETY_VERSION = 1 const val SAFETY_VERSION = 1
data class UiState( data class UiState(
val settingsInitialized: Boolean = false,
val settings: AppSettings = AppSettings(), val settings: AppSettings = AppSettings(),
val presets: List<SessionPreset> = Presets.builtIn, val presets: List<SessionPreset> = Presets.builtIn,
val selectedPreset: SessionPreset = Presets.builtIn.first(), val selectedPreset: SessionPreset = Presets.builtIn.first(),
@@ -35,6 +36,9 @@ data class UiState(
// Timeline program (curves) used for both editing + runtime modulation. // Timeline program (curves) used for both editing + runtime modulation.
val timeline: TimelineEditorState = TimelineProgramFactory.fromPreset(Presets.builtIn.first()), val timeline: TimelineEditorState = TimelineProgramFactory.fromPreset(Presets.builtIn.first()),
val savedProgramName: String = Presets.builtIn.first().name,
val savedTimelineSignature: TimelineProgramSignature = timelineProgramSignature(TimelineProgramFactory.fromPreset(Presets.builtIn.first())),
val hasUnsavedChanges: Boolean = false,
val runtimeState: RuntimeState = RuntimeState.IDLE, val runtimeState: RuntimeState = RuntimeState.IDLE,
val error: String? = null, val error: String? = null,
@@ -47,6 +51,30 @@ data class UiState(
val runtimeParams: RuntimeParams = RuntimeParams(), val runtimeParams: RuntimeParams = RuntimeParams(),
) )
data class TimelineProgramSignature(
val durationSec: Int,
val curveGranularitySec: Int,
val visualLeft: List<TimelinePoint>,
val visualRight: List<TimelinePoint>,
val audioLeft: List<TimelinePoint>,
val audioRight: List<TimelinePoint>,
)
private fun timelineProgramSignature(timeline: TimelineEditorState): TimelineProgramSignature = TimelineProgramSignature(
durationSec = timeline.durationSec,
curveGranularitySec = timeline.curveGranularitySec,
visualLeft = timeline.visualLeft.points,
visualRight = timeline.visualRight.points,
audioLeft = timeline.audioLeft.points,
audioRight = timeline.audioRight.points,
)
private fun isDirty(name: String, timeline: TimelineEditorState, savedName: String, savedTimelineSignature: TimelineProgramSignature): Boolean {
val timelineChanged = timelineProgramSignature(timeline) != savedTimelineSignature
val nameChanged = name.trim() != savedName.trim()
return timelineChanged || nameChanged
}
class MainViewModel( class MainViewModel(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val userProgramRepository: UserProgramRepository, private val userProgramRepository: UserProgramRepository,
@@ -93,12 +121,19 @@ class MainViewModel(
val selectedId = settings.lastPresetId ?: current.selectedPreset.id val selectedId = settings.lastPresetId ?: current.selectedPreset.id
val selectedPreset = allPresets.find { it.id == selectedId } ?: allPresets.first() val selectedPreset = allPresets.find { it.id == selectedId } ?: allPresets.first()
val sameSelection = selectedPreset.id == current.selectedPreset.id val sameSelection = selectedPreset.id == current.selectedPreset.id
val nextTimeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms)
val savedName = if (sameSelection) current.savedProgramName else selectedPreset.name
val savedSignature = if (sameSelection) current.savedTimelineSignature else timelineProgramSignature(nextTimeline)
current.copy( current.copy(
settingsInitialized = true,
settings = settings, settings = settings,
presets = allPresets, presets = allPresets,
selectedPreset = selectedPreset, selectedPreset = selectedPreset,
config = current.config.copy(mode = settings.defaultModePreference), config = current.config.copy(mode = SessionMode.AUDIO_VISUAL),
timeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms), timeline = nextTimeline,
savedProgramName = savedName,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = isDirty(selectedPreset.name, nextTimeline, savedName, savedSignature),
) )
} }
} }
@@ -112,11 +147,16 @@ class MainViewModel(
val preset = state.presets.first { it.id == id } val preset = state.presets.first { it.id == id }
settingsRepository.updateLastPreset(id) settingsRepository.updateLastPreset(id)
_ui.update { _ui.update {
val cfg = preset.toConfig(it.settings.defaultModePreference) val cfg = preset.toConfig(SessionMode.AUDIO_VISUAL)
val timeline = timelineForProgramId(preset.id, cachedPrograms)
val savedSignature = timelineProgramSignature(timeline)
it.copy( it.copy(
selectedPreset = preset, selectedPreset = preset,
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)), config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
timeline = timelineForProgramId(preset.id, cachedPrograms), timeline = timeline,
savedProgramName = preset.name,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null, error = null,
) )
} }
@@ -136,10 +176,14 @@ class MainViewModel(
isUserProgram = true, isUserProgram = true,
) )
_ui.update { _ui.update {
val savedSignature = timelineProgramSignature(timeline)
it.copy( it.copy(
selectedPreset = preset, selectedPreset = preset,
config = preset.toConfig(it.settings.defaultModePreference), config = preset.toConfig(SessionMode.AUDIO_VISUAL),
timeline = timeline, timeline = timeline,
savedProgramName = displayName,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null, error = null,
) )
} }
@@ -176,8 +220,12 @@ class MainViewModel(
sortOrder = sortOrder, sortOrder = sortOrder,
isUserProgram = true, isUserProgram = true,
) )
val savedSignature = timelineProgramSignature(it.timeline)
it.copy( it.copy(
selectedPreset = savedPreset, selectedPreset = savedPreset,
savedProgramName = trimmed,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = "Saved \"$trimmed\"", error = "Saved \"$trimmed\"",
) )
} }
@@ -189,10 +237,15 @@ class MainViewModel(
val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first() val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first()
settingsRepository.updateLastPreset(fallback.id) settingsRepository.updateLastPreset(fallback.id)
_ui.update { _ui.update {
val timeline = timelineForProgramId(fallback.id, cachedPrograms)
val savedSignature = timelineProgramSignature(timeline)
it.copy( it.copy(
selectedPreset = fallback, selectedPreset = fallback,
config = fallback.toConfig(it.settings.defaultModePreference), config = fallback.toConfig(SessionMode.AUDIO_VISUAL),
timeline = timelineForProgramId(fallback.id, cachedPrograms), timeline = timeline,
savedProgramName = fallback.name,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null, error = null,
) )
} }
@@ -203,16 +256,20 @@ class MainViewModel(
fun setDurationSec(seconds: Int) = _ui.update { fun setDurationSec(seconds: Int) = _ui.update {
val clamped = seconds.coerceIn(60, 8 * 60 * 60) val clamped = seconds.coerceIn(60, 8 * 60 * 60)
val nextTimeline = it.timeline.setDurationSec(clamped)
it.copy( it.copy(
config = it.config.copy(durationSec = clamped), config = it.config.copy(durationSec = clamped),
timeline = it.timeline.setDurationSec(clamped), timeline = nextTimeline,
hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature),
error = null, error = null,
) )
} }
fun setCurveGranularitySec(seconds: Int) = _ui.update { fun setCurveGranularitySec(seconds: Int) = _ui.update {
val nextTimeline = it.timeline.setCurveGranularitySec(seconds)
it.copy( it.copy(
timeline = it.timeline.setCurveGranularitySec(seconds), timeline = nextTimeline,
hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature),
error = null, error = null,
) )
} }
@@ -221,17 +278,22 @@ class MainViewModel(
it.copy( it.copy(
timeline = newState, timeline = newState,
config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)), config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)),
hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
error = null, error = null,
) )
} }
fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) } fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) }
fun setCarrier(value: Float) = _ui.update { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), error = null) } fun setCarrier(value: Float) = _ui.update {
val stableCarrier = value.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
it.copy(config = it.config.copy(carrierFrequencyHz = stableCarrier), error = null)
}
fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) } fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }
fun updateCountdownSeconds(seconds: Int) = viewModelScope.launch { settingsRepository.updateCountdownSeconds(seconds) } fun updateCountdownSeconds(seconds: Int) = viewModelScope.launch { settingsRepository.updateCountdownSeconds(seconds) }
fun updateDefaultMode(mode: SessionMode) = viewModelScope.launch { settingsRepository.updateDefaultMode(mode) } fun updateForceAudioWithoutHeadphones(value: Boolean) = viewModelScope.launch {
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) } settingsRepository.updateForceAudioWithoutHeadphones(value)
}
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) } fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
fun startSession(): Boolean { fun startSession(): Boolean {
@@ -240,8 +302,7 @@ class MainViewModel(
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") } _ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
return false return false
} }
val headset = headsetMonitor.isStereoHeadsetAvailable() val validation = SessionValidator.validate(state.config)
val validation = SessionValidator.validate(state.config, headset)
if (validation != null) { if (validation != null) {
_ui.update { it.copy(error = validation) } _ui.update { it.copy(error = validation) }
return false return false
@@ -268,10 +329,6 @@ class MainViewModel(
fun resume() { fun resume() {
val state = _ui.value val state = _ui.value
if (state.runtimeState != RuntimeState.PAUSED && state.runtimeState != RuntimeState.INTERRUPTED) return if (state.runtimeState != RuntimeState.PAUSED && state.runtimeState != RuntimeState.INTERRUPTED) return
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
_ui.update { it.copy(error = "Headphones are required to resume audio mode.") }
return
}
runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true) runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true)
} }
@@ -283,7 +340,13 @@ class MainViewModel(
} }
fun switchToVisualOnlyAndResume() { fun switchToVisualOnlyAndResume() {
_ui.update { it.copy(config = it.config.copy(mode = SessionMode.VISUAL_ONLY), error = null) } _ui.update {
it.copy(
settings = it.settings.copy(forceAudioWithoutHeadphones = false),
error = null,
)
}
viewModelScope.launch { settingsRepository.updateForceAudioWithoutHeadphones(false) }
resume() resume()
} }
@@ -331,8 +394,10 @@ class MainViewModel(
) )
} }
if (_ui.value.config.mode != SessionMode.VISUAL_ONLY) { var isAudioPlaying = false
if (shouldPlayAudio(_ui.value.settings, headsetMonitor.isStereoHeadsetAvailable())) {
audioEngine.start(initialParams.carrierHz, initialParams.binauralHz) audioEngine.start(initialParams.carrierHz, initialParams.binauralHz)
isAudioPlaying = true
} }
val tStart = System.currentTimeMillis() val tStart = System.currentTimeMillis()
@@ -343,23 +408,22 @@ class MainViewModel(
elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat()) elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat())
val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0) val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0)
val currentUi = _ui.value val currentUi = _ui.value
val headsetAvailable = headsetMonitor.isStereoHeadsetAvailable()
if (currentUi.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) { val shouldPlayAudioNow = shouldPlayAudio(currentUi.settings, headsetAvailable)
audioEngine.stop()
_ui.update {
it.copy(
runtimeState = RuntimeState.INTERRUPTED,
interruptionReason = "Headphones disconnected. Session paused.",
)
}
return@launch
}
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedBeforePauseSec) val params = TimelineRuntimeEvaluator.evaluate(program, elapsedBeforePauseSec)
_ui.update { it.copy(runtimeParams = params, remainingSec = remaining) } _ui.update { it.copy(runtimeParams = params, remainingSec = remaining) }
if (currentUi.config.mode != SessionMode.VISUAL_ONLY) { if (shouldPlayAudioNow) {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz) if (!isAudioPlaying) {
audioEngine.start(params.carrierHz, params.binauralHz)
isAudioPlaying = true
} else {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
}
} else if (isAudioPlaying) {
audioEngine.stop()
isAudioPlaying = false
} }
if (elapsedBeforePauseSec >= durationSec.toFloat()) break if (elapsedBeforePauseSec >= durationSec.toFloat()) break
@@ -371,6 +435,10 @@ class MainViewModel(
} }
} }
private fun shouldPlayAudio(settings: AppSettings, headsetAvailable: Boolean): Boolean {
return settings.forceAudioWithoutHeadphones || headsetAvailable
}
private fun currentElapsedSec(): Float { private fun currentElapsedSec(): Float {
val state = _ui.value val state = _ui.value
val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f) val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f)

View File

@@ -1,17 +1,15 @@
package com.mindmachine.mvp.session package com.mindmachine.mvp.session
import com.mindmachine.mvp.domain.SessionConfig import com.mindmachine.mvp.domain.SessionConfig
import com.mindmachine.mvp.domain.SessionMode
object SessionValidator { object SessionValidator {
fun validate(config: SessionConfig, headsetAvailable: Boolean): String? { fun validate(config: SessionConfig): String? {
if (config.durationSec !in 60..(8 * 60 * 60)) return "Duration must be 1 minute to 8 hours." if (config.durationSec !in 60..(8 * 60 * 60)) return "Duration must be 1 minute to 8 hours."
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds." 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 between 80 and 400." val carrier = config.carrierFrequencyHz
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20." if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) {
if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) { return "Carrier frequency must be between ${ParameterRanges.CARRIER_HZ_MIN.toInt()} and ${ParameterRanges.CARRIER_HZ_MAX.toInt()} Hz."
return "Stereo headphones are required for binaural audio. Connect headphones or switch to Visual-only."
} }
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20."
return null return null
} }
} }

View File

@@ -21,26 +21,16 @@ class SessionValidatorTest {
) )
@Test @Test
fun valid_with_headset_passes() { fun valid_config_passes() {
assertNull(SessionValidator.validate(valid, headsetAvailable = true)) assertNull(SessionValidator.validate(valid))
}
@Test
fun audio_without_headset_is_blocked() {
val error = SessionValidator.validate(valid, headsetAvailable = false)
assertEquals("Stereo headphones are required for binaural audio. Connect headphones or switch to Visual-only.", error)
}
@Test
fun visual_only_without_headset_passes() {
assertNull(SessionValidator.validate(valid.copy(mode = SessionMode.VISUAL_ONLY), headsetAvailable = false))
} }
@Test @Test
fun out_of_range_values_are_blocked() { fun out_of_range_values_are_blocked() {
assertEquals("Duration must be 1 minute to 8 hours.", SessionValidator.validate(valid.copy(durationSec = 59), true)) assertEquals("Duration must be 1 minute to 8 hours.", SessionValidator.validate(valid.copy(durationSec = 59)))
assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49), true)) assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49)))
assertEquals("Carrier frequency must be between 80 and 400.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 79f), true)) assertEquals("Carrier frequency must be between 200 and 1200 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 0f)))
assertEquals("Binaural difference must be between 0.5 and 20.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f), true)) assertEquals("Carrier frequency must be between 200 and 1200 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 20001f)))
assertEquals("Binaural difference must be between 0.5 and 20.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f)))
} }
} }

View File

@@ -1,6 +1,7 @@
package com.mindmachine.mvp package com.mindmachine.mvp
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -15,4 +16,37 @@ class SetupScreenUiLogicTest {
fun setup_screen_layout_uses_scrollable_container() { fun setup_screen_layout_uses_scrollable_container() {
assertTrue(setupScreenUsesScrollableContainer()) assertTrue(setupScreenUsesScrollableContainer())
} }
@Test
fun setup_screen_unsaved_changes_true_when_timeline_dirty() {
assertTrue(
setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges = true,
editedProgramName = "Deep Release",
savedProgramName = "Deep Release",
)
)
}
@Test
fun setup_screen_unsaved_changes_true_when_name_changed() {
assertTrue(
setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges = false,
editedProgramName = "Deep Release v2",
savedProgramName = "Deep Release",
)
)
}
@Test
fun setup_screen_unsaved_changes_false_when_name_and_timeline_match() {
assertFalse(
setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges = false,
editedProgramName = "Deep Release",
savedProgramName = "Deep Release",
)
)
}
} }