diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index d7ed181..0c28949 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -79,7 +79,6 @@ import com.mindmachine.mvp.audio.HeadsetMonitor import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.data.UserProgramRepository import com.mindmachine.mvp.domain.RuntimeState -import com.mindmachine.mvp.domain.SessionMode import com.mindmachine.mvp.session.FlashColor import com.mindmachine.mvp.session.MainViewModel import com.mindmachine.mvp.session.SplitFlashFrame @@ -144,6 +143,15 @@ fun App(vm: MainViewModel = viewModel()) { 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" NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) { composable("welcome") { @@ -235,10 +243,10 @@ fun App(vm: MainViewModel = viewModel()) { } } composable("settings") { - SettingsScreen(vm) { nav.popBackStack() } + SettingsScreen(vm) } composable("holder") { - HolderScreen { nav.popBackStack() } + HolderScreen() } } } @@ -281,7 +289,6 @@ fun SetupScreen( onHolder: () -> Unit, ) { val ui by vm.ui.collectAsStateWithLifecycle() - val cfg = ui.config val scrollState = rememberScrollState() val containerModifier = Modifier .fillMaxSize() @@ -292,6 +299,12 @@ fun SetupScreen( var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) } + val hasUnsavedChanges = setupScreenHasUnsavedChanges( + hasUnsavedTimelineChanges = ui.hasUnsavedChanges, + editedProgramName = programName, + savedProgramName = ui.savedProgramName, + ) + Column( modifier = containerModifier, verticalArrangement = Arrangement.spacedBy(14.dp), @@ -323,10 +336,13 @@ fun SetupScreen( TextButton(onClick = onHolder) { Text("Holder Guidance") } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) { - Text("Save") + if (hasUnsavedChanges) { + 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) Spacer(Modifier.height(8.dp)) @@ -336,6 +352,15 @@ fun SetupScreen( internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String = "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 @Composable @@ -348,7 +373,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup: onBackToSetup() } - val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY + val isVisualMode = true val context = LocalContext.current DisposableEffect(isVisualMode) { val activity = context as? Activity @@ -376,7 +401,6 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup: } val flashFrame by rememberSplitFlashFrame( isRunning = ui.runtimeState == RuntimeState.RUNNING, - mode = ui.config.mode, flashOnMs = ui.runtimeParams.flashOnMs, flashOffMs = ui.runtimeParams.flashOffMs, ) @@ -506,15 +530,14 @@ private fun FlashColor.toComposeColor(): Color = when (this) { @Composable private fun rememberSplitFlashFrame( isRunning: Boolean, - mode: SessionMode, flashOnMs: Int, flashOffMs: Int, ): androidx.compose.runtime.State { val latestFlashOnMs = rememberUpdatedState(flashOnMs) val latestFlashOffMs = rememberUpdatedState(flashOffMs) - return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode) { - if (!isRunning || mode == SessionMode.AUDIO_ONLY) { + return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning) { + if (!isRunning) { value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) return@produceState } @@ -535,7 +558,7 @@ private fun rememberSplitFlashFrame( } @Composable -fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { +fun SettingsScreen(vm: MainViewModel) { val ui by vm.ui.collectAsStateWithLifecycle() Column( Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), @@ -551,16 +574,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { 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) { - Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance) - Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground) + Checkbox( + checked = ui.settings.forceAudioWithoutHeadphones, + onCheckedChange = vm::updateForceAudioWithoutHeadphones, + ) + Text("force audio without headphones", color = MaterialTheme.colorScheme.onBackground) } Row(verticalAlignment = Alignment.CenterVertically) { @@ -570,12 +589,11 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { HorizontalDivider() 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) { +fun HolderScreen() { Column( Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) @@ -587,6 +605,5 @@ fun HolderScreen(onBack: () -> Unit) { 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/data/SettingsRepository.kt b/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt index 1155512..c81bb3a 100644 --- a/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt +++ b/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt @@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.mindmachine.mvp.domain.AppSettings -import com.mindmachine.mvp.domain.SessionMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -19,8 +18,7 @@ class SettingsRepository(private val context: Context) { val safetyVersion = intPreferencesKey("safety_version") val countdownPref = stringPreferencesKey("countdown_pref") val countdownSeconds = intPreferencesKey("countdown_seconds") - val defaultMode = stringPreferencesKey("default_mode") - val showGuidance = booleanPreferencesKey("show_guidance") + val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones") val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar") val lastPresetId = stringPreferencesKey("last_preset") } @@ -32,8 +30,7 @@ class SettingsRepository(private val context: Context) { countdownSeconds = p[Keys.countdownSeconds] ?: legacyCountdownStringToSeconds(p[Keys.countdownPref]) ?: 5, - defaultModePreference = runCatching { SessionMode.valueOf(p[Keys.defaultMode] ?: "AUDIO_VISUAL") }.getOrDefault(SessionMode.AUDIO_VISUAL), - showHolderGuidanceBeforeSession = p[Keys.showGuidance] ?: false, + forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false, showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true, lastPresetId = p[Keys.lastPresetId], ) @@ -50,9 +47,14 @@ class SettingsRepository(private val context: Context) { it[Keys.countdownSeconds] = value.coerceIn(0, 10) } - suspend fun updateDefaultMode(value: SessionMode) = context.dataStore.edit { it[Keys.defaultMode] = value.name } - suspend fun updateGuidance(value: Boolean) = context.dataStore.edit { it[Keys.showGuidance] = value } - suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit { it[Keys.showImmersiveProgressBar] = value } + suspend fun updateForceAudioWithoutHeadphones(value: Boolean) = context.dataStore.edit { + it[Keys.forceAudioWithoutHeadphones] = 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 } private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) { diff --git a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt index 9babd52..f505462 100644 --- a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt +++ b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt @@ -34,8 +34,7 @@ data class AppSettings( val safetyAcknowledged: Boolean = false, val safetyAcknowledgedVersion: Int = 0, val countdownSeconds: Int = 5, - val defaultModePreference: SessionMode = SessionMode.AUDIO_VISUAL, - val showHolderGuidanceBeforeSession: Boolean = false, + val forceAudioWithoutHeadphones: Boolean = false, val showImmersiveProgressBar: Boolean = true, val lastPresetId: String? = null, ) diff --git a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt index 730d49c..15e45c5 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -28,6 +28,7 @@ import java.util.UUID const val SAFETY_VERSION = 1 data class UiState( + val settingsInitialized: Boolean = false, val settings: AppSettings = AppSettings(), val presets: List = Presets.builtIn, val selectedPreset: SessionPreset = Presets.builtIn.first(), @@ -35,6 +36,9 @@ data class UiState( // Timeline program (curves) used for both editing + runtime modulation. 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 error: String? = null, @@ -47,6 +51,30 @@ data class UiState( val runtimeParams: RuntimeParams = RuntimeParams(), ) +data class TimelineProgramSignature( + val durationSec: Int, + val curveGranularitySec: Int, + val visualLeft: List, + val visualRight: List, + val audioLeft: List, + val audioRight: List, +) + +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( private val settingsRepository: SettingsRepository, private val userProgramRepository: UserProgramRepository, @@ -93,12 +121,19 @@ class MainViewModel( val selectedId = settings.lastPresetId ?: current.selectedPreset.id val selectedPreset = allPresets.find { it.id == selectedId } ?: allPresets.first() 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( + settingsInitialized = true, settings = settings, presets = allPresets, selectedPreset = selectedPreset, - config = current.config.copy(mode = settings.defaultModePreference), - timeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms), + config = current.config.copy(mode = SessionMode.AUDIO_VISUAL), + 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 } settingsRepository.updateLastPreset(id) _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( selectedPreset = preset, 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, ) } @@ -136,10 +176,14 @@ class MainViewModel( isUserProgram = true, ) _ui.update { + val savedSignature = timelineProgramSignature(timeline) it.copy( selectedPreset = preset, - config = preset.toConfig(it.settings.defaultModePreference), + config = preset.toConfig(SessionMode.AUDIO_VISUAL), timeline = timeline, + savedProgramName = displayName, + savedTimelineSignature = savedSignature, + hasUnsavedChanges = false, error = null, ) } @@ -176,8 +220,12 @@ class MainViewModel( sortOrder = sortOrder, isUserProgram = true, ) + val savedSignature = timelineProgramSignature(it.timeline) it.copy( selectedPreset = savedPreset, + savedProgramName = trimmed, + savedTimelineSignature = savedSignature, + hasUnsavedChanges = false, error = "Saved \"$trimmed\"", ) } @@ -189,10 +237,15 @@ class MainViewModel( val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first() settingsRepository.updateLastPreset(fallback.id) _ui.update { + val timeline = timelineForProgramId(fallback.id, cachedPrograms) + val savedSignature = timelineProgramSignature(timeline) it.copy( selectedPreset = fallback, - config = fallback.toConfig(it.settings.defaultModePreference), - timeline = timelineForProgramId(fallback.id, cachedPrograms), + config = fallback.toConfig(SessionMode.AUDIO_VISUAL), + timeline = timeline, + savedProgramName = fallback.name, + savedTimelineSignature = savedSignature, + hasUnsavedChanges = false, error = null, ) } @@ -203,16 +256,20 @@ class MainViewModel( fun setDurationSec(seconds: Int) = _ui.update { val clamped = seconds.coerceIn(60, 8 * 60 * 60) + val nextTimeline = it.timeline.setDurationSec(clamped) it.copy( 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, ) } fun setCurveGranularitySec(seconds: Int) = _ui.update { + val nextTimeline = it.timeline.setCurveGranularitySec(seconds) it.copy( - timeline = it.timeline.setCurveGranularitySec(seconds), + timeline = nextTimeline, + hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature), error = null, ) } @@ -221,17 +278,22 @@ class MainViewModel( it.copy( timeline = newState, config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)), + hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature), 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 updateCountdownSeconds(seconds: Int) = viewModelScope.launch { settingsRepository.updateCountdownSeconds(seconds) } - fun updateDefaultMode(mode: SessionMode) = viewModelScope.launch { settingsRepository.updateDefaultMode(mode) } - fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) } + fun updateForceAudioWithoutHeadphones(value: Boolean) = viewModelScope.launch { + settingsRepository.updateForceAudioWithoutHeadphones(value) + } fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) } fun startSession(): Boolean { @@ -240,8 +302,7 @@ class MainViewModel( _ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") } return false } - val headset = headsetMonitor.isStereoHeadsetAvailable() - val validation = SessionValidator.validate(state.config, headset) + val validation = SessionValidator.validate(state.config) if (validation != null) { _ui.update { it.copy(error = validation) } return false @@ -268,10 +329,6 @@ class MainViewModel( fun resume() { val state = _ui.value 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) } @@ -283,7 +340,13 @@ class MainViewModel( } 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() } @@ -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) + isAudioPlaying = true } val tStart = System.currentTimeMillis() @@ -343,23 +408,22 @@ class MainViewModel( elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat()) val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0) val currentUi = _ui.value - - if (currentUi.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) { - audioEngine.stop() - _ui.update { - it.copy( - runtimeState = RuntimeState.INTERRUPTED, - interruptionReason = "Headphones disconnected. Session paused.", - ) - } - return@launch - } + val headsetAvailable = headsetMonitor.isStereoHeadsetAvailable() + val shouldPlayAudioNow = shouldPlayAudio(currentUi.settings, headsetAvailable) val params = TimelineRuntimeEvaluator.evaluate(program, elapsedBeforePauseSec) _ui.update { it.copy(runtimeParams = params, remainingSec = remaining) } - if (currentUi.config.mode != SessionMode.VISUAL_ONLY) { - audioEngine.setFrequencies(params.carrierHz, params.binauralHz) + if (shouldPlayAudioNow) { + 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 @@ -371,6 +435,10 @@ class MainViewModel( } } + private fun shouldPlayAudio(settings: AppSettings, headsetAvailable: Boolean): Boolean { + return settings.forceAudioWithoutHeadphones || headsetAvailable + } + private fun currentElapsedSec(): Float { val state = _ui.value val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f) 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 b25c7f4..ae1f6fc 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt @@ -1,17 +1,15 @@ package com.mindmachine.mvp.session import com.mindmachine.mvp.domain.SessionConfig -import com.mindmachine.mvp.domain.SessionMode - 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.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." - if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20." - if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) { - return "Stereo headphones are required for binaural audio. Connect headphones or switch to Visual-only." + val carrier = config.carrierFrequencyHz + if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) { + return "Carrier frequency must be between ${ParameterRanges.CARRIER_HZ_MIN.toInt()} and ${ParameterRanges.CARRIER_HZ_MAX.toInt()} Hz." } + if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20." return null } } diff --git a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt index 182fc13..df1f145 100644 --- a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt @@ -21,26 +21,16 @@ class SessionValidatorTest { ) @Test - fun valid_with_headset_passes() { - assertNull(SessionValidator.validate(valid, headsetAvailable = true)) - } - - @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)) + fun valid_config_passes() { + assertNull(SessionValidator.validate(valid)) } @Test fun out_of_range_values_are_blocked() { - assertEquals("Duration must be 1 minute to 8 hours.", SessionValidator.validate(valid.copy(durationSec = 59), true)) - assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49), true)) - assertEquals("Carrier frequency must be between 80 and 400.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 79f), true)) - assertEquals("Binaural difference must be between 0.5 and 20.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f), 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))) + assertEquals("Carrier frequency must be between 200 and 1200 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 0f))) + 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))) } } diff --git a/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt b/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt index bce303f..cfbda64 100644 --- a/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt @@ -1,6 +1,7 @@ package com.mindmachine.mvp import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -15,4 +16,37 @@ class SetupScreenUiLogicTest { fun setup_screen_layout_uses_scrollable_container() { 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", + ) + ) + } }