feat(mindmachine): implement Android MVP app scaffold, session flows, safety gating, and tests
This commit is contained in:
181
app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt
Normal file
181
app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt
Normal file
@@ -0,0 +1,181 @@
|
||||
package com.mindmachine.mvp.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mindmachine.mvp.audio.BinauralAudioEngine
|
||||
import com.mindmachine.mvp.audio.HeadsetMonitor
|
||||
import com.mindmachine.mvp.data.SettingsRepository
|
||||
import com.mindmachine.mvp.domain.AppSettings
|
||||
import com.mindmachine.mvp.domain.CountdownPreference
|
||||
import com.mindmachine.mvp.domain.Presets
|
||||
import com.mindmachine.mvp.domain.RuntimeState
|
||||
import com.mindmachine.mvp.domain.SessionConfig
|
||||
import com.mindmachine.mvp.domain.SessionMode
|
||||
import com.mindmachine.mvp.domain.SessionPreset
|
||||
import com.mindmachine.mvp.domain.toConfig
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
const val SAFETY_VERSION = 1
|
||||
|
||||
data class UiState(
|
||||
val settings: AppSettings = AppSettings(),
|
||||
val presets: List<SessionPreset> = Presets.builtIn,
|
||||
val selectedPreset: SessionPreset = Presets.builtIn.first(),
|
||||
val config: SessionConfig = Presets.builtIn.first().toConfig(),
|
||||
val runtimeState: RuntimeState = RuntimeState.IDLE,
|
||||
val error: String? = null,
|
||||
val remainingSec: Int = 0,
|
||||
val countdownSec: Int = 0,
|
||||
val endedEarly: Boolean = false,
|
||||
val interruptionReason: String? = null,
|
||||
)
|
||||
|
||||
class MainViewModel(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val headsetMonitor: HeadsetMonitor,
|
||||
private val audioEngine: BinauralAudioEngine,
|
||||
) : ViewModel() {
|
||||
private val _ui = MutableStateFlow(UiState())
|
||||
val ui: StateFlow<UiState> = _ui.asStateFlow()
|
||||
|
||||
private var runJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.settings.collect { s ->
|
||||
_ui.update { current ->
|
||||
val preset = current.presets.find { it.id == (s.lastPresetId ?: current.selectedPreset.id) } ?: current.selectedPreset
|
||||
current.copy(settings = s, selectedPreset = preset, config = current.config.copy(mode = s.defaultModePreference))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acknowledgeSafety() = viewModelScope.launch { settingsRepository.acknowledgeSafety(SAFETY_VERSION) }
|
||||
fun choosePreset(id: String) = viewModelScope.launch {
|
||||
val preset = _ui.value.presets.first { it.id == id }
|
||||
settingsRepository.updateLastPreset(id)
|
||||
_ui.update { it.copy(selectedPreset = preset, config = preset.toConfig(it.settings.defaultModePreference), error = null) }
|
||||
}
|
||||
|
||||
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
|
||||
fun setDurationMin(min: Int) = _ui.update { it.copy(config = it.config.copy(durationSec = (min.coerceIn(1, 30) * 60)), error = null) }
|
||||
fun setBlinkFrequency(value: Float) = _ui.update { it.copy(config = it.config.copy(blinkFrequencyHz = value.coerceIn(1f, 20f)), error = null) }
|
||||
fun setCarrier(value: Float) = _ui.update { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), error = null) }
|
||||
fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }
|
||||
|
||||
fun updateCountdown(pref: CountdownPreference) = viewModelScope.launch { settingsRepository.updateCountdown(pref) }
|
||||
fun updateDefaultMode(mode: SessionMode) = viewModelScope.launch { settingsRepository.updateDefaultMode(mode) }
|
||||
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
|
||||
|
||||
fun startSession() {
|
||||
val state = _ui.value
|
||||
if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) {
|
||||
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
|
||||
return
|
||||
}
|
||||
val headset = headsetMonitor.isStereoHeadsetAvailable()
|
||||
val validation = SessionValidator.validate(state.config, headset)
|
||||
if (validation != null) {
|
||||
_ui.update { it.copy(error = validation) }
|
||||
return
|
||||
}
|
||||
runJob?.cancel()
|
||||
runJob = viewModelScope.launch {
|
||||
val count = state.settings.countdownPreference.seconds
|
||||
if (count > 0) {
|
||||
for (i in count downTo 1) {
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.COUNTDOWN, countdownSec = i, remainingSec = state.config.durationSec, endedEarly = false, interruptionReason = null) }
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, remainingSec = state.config.durationSec, countdownSec = 0, error = null) }
|
||||
if (state.config.mode != SessionMode.VISUAL_ONLY) {
|
||||
audioEngine.start(state.config.carrierFrequencyHz, state.config.binauralDifferenceHz)
|
||||
}
|
||||
var remaining = state.config.durationSec
|
||||
while (remaining > 0) {
|
||||
delay(1000)
|
||||
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
|
||||
audioEngine.stop()
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
|
||||
return@launch
|
||||
}
|
||||
remaining -= 1
|
||||
_ui.update { it.copy(remainingSec = remaining) }
|
||||
}
|
||||
audioEngine.stop()
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun pause(reason: String? = null) {
|
||||
if (_ui.value.runtimeState != RuntimeState.RUNNING) return
|
||||
runJob?.cancel()
|
||||
audioEngine.stop()
|
||||
_ui.update { it.copy(runtimeState = if (reason == null) RuntimeState.PAUSED else RuntimeState.INTERRUPTED, interruptionReason = reason) }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
runJob = viewModelScope.launch {
|
||||
for (i in 3 downTo 1) {
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.COUNTDOWN, countdownSec = i) }
|
||||
delay(1000)
|
||||
}
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, countdownSec = 0) }
|
||||
if (state.config.mode != SessionMode.VISUAL_ONLY) {
|
||||
audioEngine.start(state.config.carrierFrequencyHz, state.config.binauralDifferenceHz)
|
||||
}
|
||||
var remaining = state.remainingSec
|
||||
while (remaining > 0) {
|
||||
delay(1000)
|
||||
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
|
||||
audioEngine.stop()
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
|
||||
return@launch
|
||||
}
|
||||
remaining -= 1
|
||||
_ui.update { it.copy(remainingSec = remaining) }
|
||||
}
|
||||
audioEngine.stop()
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
runJob?.cancel()
|
||||
audioEngine.stop()
|
||||
_ui.update { it.copy(runtimeState = RuntimeState.STOPPED, endedEarly = true) }
|
||||
}
|
||||
|
||||
fun switchToVisualOnlyAndResume() {
|
||||
_ui.update { it.copy(config = it.config.copy(mode = SessionMode.VISUAL_ONLY), error = null) }
|
||||
resume()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
audioEngine.stop()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val headsetMonitor: HeadsetMonitor,
|
||||
private val audioEngine: BinauralAudioEngine,
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel(settingsRepository, headsetMonitor, audioEngine) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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? {
|
||||
if (config.durationSec !in 60..(30 * 60)) return "Duration must be 1 to 30 minutes."
|
||||
if (config.blinkFrequencyHz !in 1f..20f) return "Blink frequency must be 1.0 to 20.0 Hz."
|
||||
if (config.carrierFrequencyHz !in 80f..400f) return "Carrier frequency must be 80 to 400 Hz."
|
||||
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be 0.5 to 20 Hz."
|
||||
if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) {
|
||||
return "Stereo headphones are required for binaural audio. Connect headphones or switch to Visual-only."
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user