Auto commit Sun Mar 22 01:01:14 AM CDT 2026
This commit is contained in:
@@ -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") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user