feat(mindmachine): implement Android MVP app scaffold, session flows, safety gating, and tests

This commit is contained in:
Tretzi
2026-03-10 22:50:50 -05:00
commit 83a0586d26
22 changed files with 2671 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:icon="@android:drawable/ic_dialog_info"
android:label="MindMachine"
android:roundIcon="@android:drawable/ic_dialog_info"
android:supportsRtl="true"
android:theme="@style/Theme.MindMachine">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,307 @@
package com.mindmachine.mvp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.mindmachine.mvp.audio.BinauralAudioEngine
import com.mindmachine.mvp.audio.HeadsetMonitor
import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.domain.CountdownPreference
import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.domain.SessionMode
import com.mindmachine.mvp.session.MainViewModel
import kotlin.math.roundToInt
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels {
MainViewModel.Factory(SettingsRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App(vm) }
}
}
@Composable
fun App(vm: MainViewModel = viewModel()) {
val nav = rememberNavController()
val ui by vm.ui.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
androidx.compose.runtime.DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && ui.runtimeState == RuntimeState.RUNNING) {
vm.pause("Session paused because app moved to background.")
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome"
NavHost(navController = nav, startDestination = startRoute) {
composable("welcome") {
SimpleScreen("MindMachine", "Blinking light + binaural audio. Stereo headphones required for binaural mode. Not a medical device.") {
Button(onClick = { nav.navigate("safety") }) { Text("Continue") }
}
}
composable("safety") {
SafetyScreen(
onAck = {
vm.acknowledgeSafety()
nav.navigate("home") { popUpTo(0) }
},
onHolder = { nav.navigate("holder") }
)
}
composable("home") {
Column(Modifier.fillMaxSize()) {
TopAppBar(title = { Text("MindMachine") }, actions = {
TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") }
})
LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
TextButton(onClick = { nav.navigate("holder") }) { Text("Holder Guidance") }
}
items(ui.presets) { p ->
Card(Modifier.fillMaxWidth().clickable {
vm.choosePreset(p.id)
nav.navigate("setup")
}.padding(4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(p.name, style = MaterialTheme.typography.titleMedium)
Text(p.description)
Text("${p.defaultDurationSec / 60} min • ${p.visualPatternType}${p.binauralDifferenceHz} Hz")
}
}
}
}
}
}
composable("setup") {
SetupScreen(vm = vm, onStart = {
vm.startSession()
nav.navigate("active")
}, onHolder = { nav.navigate("holder") })
}
composable("active") {
ActiveSessionScreen(vm = vm, onFinish = { nav.navigate("complete") { popUpTo("setup") } })
}
composable("complete") {
SimpleScreen(
if (ui.endedEarly) "Session ended" else "Session complete.",
ui.selectedPreset.name
) {
Button(onClick = {
vm.startSession()
nav.navigate("active")
}) { Text("Repeat Session") }
OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") }
}
}
composable("settings") {
SettingsScreen(vm) { nav.popBackStack() }
}
composable("holder") {
HolderScreen { nav.popBackStack() }
}
}
}
@Composable
fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Text(subtitle)
actions()
}
}
@Composable
fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
var checked by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(12.dp))
Text("Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.\n\nDo not use while driving, walking, cycling, or operating machinery.\n\nStop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.\n\nBinaural mode requires stereo headphones.\n\nThis app is not a medical device.")
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = { checked = it })
Text("I understand the risks and will stop immediately if I feel discomfort.")
}
Button(onClick = onAck, enabled = checked, modifier = Modifier.semantics { contentDescription = "I Understand" }) { Text("I Understand") }
TextButton(onClick = onHolder) { Text("Holder Guidance") }
}
}
@Composable
fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
val cfg = ui.config
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall)
Text("Duration: ${cfg.durationSec / 60} min")
Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f)
Text("Mode")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionMode.values().forEach { mode ->
OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) }
}
}
Text("Blink ${cfg.blinkFrequencyHz} Hz")
Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f)
Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz")
Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f)
Text("Difference ${cfg.binauralDifferenceHz} Hz")
Slider(value = cfg.binauralDifferenceHz, onValueChange = vm::setDifference, valueRange = 0.5f..20f)
Text("Brightness recommendation: keep screen comfortable and avoid eye strain.")
TextButton(onClick = onHolder) { Text("Holder Guidance") }
if (ui.error != null) Text(ui.error!!, color = Color.Red)
Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") }
}
}
@Composable
fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish()
var showOverlay by remember { mutableStateOf(true) }
val intensity = (ui.config.intensityPercent / 100f)
val flashing = if (ui.config.visualPatternType.name == "FLASH") {
if ((ui.remainingSec % 2) == 0) intensity else 0f
} else intensity * 0.5f
Box(
modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = if (ui.config.mode == SessionMode.AUDIO_ONLY) 0f else flashing))
.clickable { showOverlay = !showOverlay }
) {
if (ui.runtimeState == RuntimeState.COUNTDOWN) {
Text(
"${ui.countdownSec}",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.displayLarge,
color = Color.Black
)
}
if (showOverlay) {
Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(Color.Black.copy(alpha = 0.6f)).padding(16.dp)) {
Text("${ui.selectedPreset.name}${ui.remainingSec}s", color = Color.White)
if (ui.runtimeState == RuntimeState.RUNNING) {
Button(onClick = { vm.pause() }, modifier = Modifier.fillMaxWidth()) { Text("Pause") }
} else {
Button(onClick = { vm.resume() }, modifier = Modifier.fillMaxWidth()) { Text("Resume") }
}
OutlinedButton(onClick = { vm.stop() }, modifier = Modifier.fillMaxWidth()) { Text("Stop") }
if (ui.interruptionReason != null) {
Text(ui.interruptionReason!!, color = Color.White)
if (ui.runtimeState == RuntimeState.INTERRUPTED) {
OutlinedButton(onClick = { vm.switchToVisualOnlyAndResume() }, modifier = Modifier.fillMaxWidth()) {
Text("Resume Visual-only")
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
var expanded by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Settings", style = MaterialTheme.typography.headlineSmall)
Text("Countdown")
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
TextButton(onClick = { expanded = true }) { Text(ui.settings.countdownPreference.name) }
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
CountdownPreference.values().forEach {
DropdownMenuItem(text = { Text(it.name) }, onClick = { vm.updateCountdown(it); expanded = false })
}
}
}
Text("Default mode")
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")
}
HorizontalDivider()
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.")
OutlinedButton(onClick = onBack) { Text("Back") }
}
}
@Composable
fun HolderScreen(onBack: () -> Unit) {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall)
Text("• Use a simple cardboard visor/holder.")
Text("• Keep phone stable and hands-free.")
Text("• Do not press device against eyes/face.")
Text("• Allow airflow and comfort.")
Text("• Test fit before session.")
Text("• Sit or lie down in a safe place.")
OutlinedButton(onClick = onBack) { Text("Back") }
}
}

View File

@@ -0,0 +1,72 @@
package com.mindmachine.mvp.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.sin
class BinauralAudioEngine {
private var track: AudioTrack? = null
private var job: Job? = null
private var scope: CoroutineScope? = null
fun start(carrierHz: Float, differenceHz: Float) {
stop()
val sampleRate = 44100
val bufferSize = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_STEREO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(4096)
val audioTrack = AudioTrack(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build(),
AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_16BIT).setSampleRate(sampleRate).setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build(),
bufferSize,
AudioTrack.MODE_STREAM,
AudioTrack.AUDIO_SESSION_ID_GENERATE
)
track = audioTrack
val localScope = CoroutineScope(Dispatchers.Default)
scope = localScope
audioTrack.play()
job = localScope.launch {
val shorts = ShortArray(bufferSize)
var phaseL = 0.0
var phaseR = 0.0
val leftHz = carrierHz - differenceHz / 2f
val rightHz = carrierHz + differenceHz / 2f
while (isActive) {
for (i in shorts.indices step 2) {
phaseL += 2 * PI * leftHz / sampleRate
phaseR += 2 * PI * rightHz / sampleRate
shorts[i] = (sin(phaseL) * Short.MAX_VALUE * 0.15).toInt().toShort()
shorts[i + 1] = (sin(phaseR) * Short.MAX_VALUE * 0.15).toInt().toShort()
}
audioTrack.write(shorts, 0, shorts.size)
}
}
}
fun stop() {
job?.cancel()
job = null
scope?.cancel()
scope = null
track?.runCatching {
pause()
flush()
stop()
release()
}
track = null
}
}

View File

@@ -0,0 +1,18 @@
package com.mindmachine.mvp.audio
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioManager
class HeadsetMonitor(context: Context) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
fun isStereoHeadsetAvailable(): Boolean {
return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any {
(it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES
|| it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET
|| it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP
|| it.type == AudioDeviceInfo.TYPE_BLE_HEADSET)
}
}
}

View File

@@ -0,0 +1,49 @@
package com.mindmachine.mvp.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
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.CountdownPreference
import com.mindmachine.mvp.domain.SessionMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("mindmachine_settings")
class SettingsRepository(private val context: Context) {
private object Keys {
val safetyAcknowledged = booleanPreferencesKey("safety_ack")
val safetyVersion = intPreferencesKey("safety_version")
val countdownPref = stringPreferencesKey("countdown_pref")
val defaultMode = stringPreferencesKey("default_mode")
val showGuidance = booleanPreferencesKey("show_guidance")
val lastPresetId = stringPreferencesKey("last_preset")
}
val settings: Flow<AppSettings> = context.dataStore.data.map { p ->
AppSettings(
safetyAcknowledged = p[Keys.safetyAcknowledged] ?: false,
safetyAcknowledgedVersion = p[Keys.safetyVersion] ?: 0,
countdownPreference = runCatching { CountdownPreference.valueOf(p[Keys.countdownPref] ?: "FIVE") }.getOrDefault(CountdownPreference.FIVE),
defaultModePreference = runCatching { SessionMode.valueOf(p[Keys.defaultMode] ?: "AUDIO_VISUAL") }.getOrDefault(SessionMode.AUDIO_VISUAL),
showHolderGuidanceBeforeSession = p[Keys.showGuidance] ?: false,
lastPresetId = p[Keys.lastPresetId],
)
}
suspend fun acknowledgeSafety(version: Int) {
context.dataStore.edit {
it[Keys.safetyAcknowledged] = true
it[Keys.safetyVersion] = version
}
}
suspend fun updateCountdown(value: CountdownPreference) = context.dataStore.edit { it[Keys.countdownPref] = value.name }
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 updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id }
}

View File

@@ -0,0 +1,71 @@
package com.mindmachine.mvp.domain
enum class VisualPattern { FLASH, PULSE }
enum class SessionMode { AUDIO_VISUAL, AUDIO_ONLY, VISUAL_ONLY }
enum class RuntimeState { IDLE, COUNTDOWN, RUNNING, PAUSED, INTERRUPTED, COMPLETED, STOPPED, ERROR }
enum class CountdownPreference(val seconds: Int) { OFF(0), FIVE(5), TEN(10) }
data class SessionPreset(
val id: String,
val name: String,
val description: String,
val defaultDurationSec: Int,
val visualPatternType: VisualPattern,
val blinkFrequencyHz: Float,
val intensityPercent: Int,
val carrierFrequencyHz: Float,
val binauralDifferenceHz: Float,
val cautionNote: String,
val sortOrder: Int,
)
data class SessionConfig(
val presetId: String,
val durationSec: Int,
val mode: SessionMode,
val visualPatternType: VisualPattern,
val blinkFrequencyHz: Float,
val intensityPercent: Int,
val carrierFrequencyHz: Float,
val binauralDifferenceHz: Float,
)
data class AppSettings(
val safetyAcknowledged: Boolean = false,
val safetyAcknowledgedVersion: Int = 0,
val countdownPreference: CountdownPreference = CountdownPreference.FIVE,
val defaultModePreference: SessionMode = SessionMode.AUDIO_VISUAL,
val showHolderGuidanceBeforeSession: Boolean = false,
val lastPresetId: String? = null,
)
object Presets {
val builtIn = listOf(
SessionPreset(
"relax", "Relax", "Gentle pulse, slower beat", 10 * 60,
VisualPattern.PULSE, 6f, 60, 200f, 6f,
"Stop if discomfort occurs.", 1
),
SessionPreset(
"focus", "Focus", "Steady flash, conservative alert beat", 15 * 60,
VisualPattern.FLASH, 10f, 65, 220f, 10f,
"Use in a safe seated place only.", 2
),
SessionPreset(
"sleep", "Sleep Prep", "Slow pulse, low intensity", 20 * 60,
VisualPattern.PULSE, 3f, 45, 180f, 3f,
"Do not use while doing other activities.", 3
)
)
}
fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL) = SessionConfig(
presetId = id,
durationSec = defaultDurationSec,
mode = defaultMode,
visualPatternType = visualPatternType,
blinkFrequencyHz = blinkFrequencyHz,
intensityPercent = intensityPercent,
carrierFrequencyHz = carrierFrequencyHz,
binauralDifferenceHz = binauralDifferenceHz,
)

View 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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MindMachine" parent="Theme.Material3.Dark.NoActionBar" />
</resources>