feat(mindmachine): implement Android MVP app scaffold, session flows, safety gating, and tests
This commit is contained in:
307
app/src/main/java/com/mindmachine/mvp/MainActivity.kt
Normal file
307
app/src/main/java/com/mindmachine/mvp/MainActivity.kt
Normal 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") }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
71
app/src/main/java/com/mindmachine/mvp/domain/Models.kt
Normal file
71
app/src/main/java/com/mindmachine/mvp/domain/Models.kt
Normal 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,
|
||||
)
|
||||
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