Auto commit Sat Mar 21 11:01:02 PM CDT 2026
This commit is contained in:
@@ -32,12 +32,10 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
@@ -79,7 +77,6 @@ import com.mindmachine.mvp.audio.BinauralAudioEngine
|
||||
import com.mindmachine.mvp.audio.HeadsetMonitor
|
||||
import com.mindmachine.mvp.data.SettingsRepository
|
||||
import com.mindmachine.mvp.data.UserProgramRepository
|
||||
import com.mindmachine.mvp.domain.CountdownPreference
|
||||
import com.mindmachine.mvp.domain.RuntimeState
|
||||
import com.mindmachine.mvp.domain.SessionMode
|
||||
import com.mindmachine.mvp.session.FlashColor
|
||||
@@ -88,7 +85,7 @@ import com.mindmachine.mvp.session.SplitFlashFrame
|
||||
import com.mindmachine.mvp.session.computeSplitFlashFrame
|
||||
import com.mindmachine.mvp.session.flashIntervalMsToSeconds
|
||||
import com.mindmachine.mvp.session.flashIntervalSecondsToMs
|
||||
import com.mindmachine.mvp.session.shouldRevealSystemBarsOnTap
|
||||
import com.mindmachine.mvp.session.sessionProgressFraction
|
||||
import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault
|
||||
import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -196,7 +193,6 @@ fun App(vm: MainViewModel = viewModel()) {
|
||||
Text(p.description)
|
||||
Text("${p.defaultDurationSec / 60} min • ${p.visualPatternType} • binaural ${p.binauralDifferenceHz}")
|
||||
}
|
||||
if (p.isUserProgram) {
|
||||
TextButton(onClick = { vm.deleteProgram(p.id) }) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
@@ -204,7 +200,6 @@ fun App(vm: MainViewModel = viewModel()) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composable("setup") {
|
||||
SetupScreen(
|
||||
vm = vm,
|
||||
@@ -380,14 +375,18 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
|
||||
flashOffMs = ui.runtimeParams.flashOffMs,
|
||||
)
|
||||
|
||||
val progress = sessionProgressFraction(
|
||||
durationSec = ui.timeline.durationSec,
|
||||
remainingSec = ui.remainingSec,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable {
|
||||
if (!immersiveFullscreen) {
|
||||
showOverlay = !showOverlay
|
||||
if (shouldRevealSystemBarsOnTap(isVisualMode, ui.runtimeState)) {
|
||||
revealSystemBarsSignal += 1
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -416,6 +415,19 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
if (immersiveFullscreen && ui.settings.showImmersiveProgressBar) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(5.dp),
|
||||
trackColor = Color.White.copy(alpha = 0.2f),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
if (showOverlay) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -503,35 +515,40 @@ private fun rememberSplitFlashFrame(
|
||||
}
|
||||
}
|
||||
|
||||
@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().background(MaterialTheme.colorScheme.background).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text("Countdown", color = MaterialTheme.colorScheme.onBackground)
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
TextButton(onClick = { expanded = true }) { Text(ui.settings.countdownPreference.name) }
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
CountdownPreference.values().forEach {
|
||||
DropdownMenuItem(text = { Text(it.name) }, onClick = { vm.updateCountdown(it); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Countdown: ${ui.settings.countdownSeconds}s", color = MaterialTheme.colorScheme.onBackground)
|
||||
Slider(
|
||||
value = ui.settings.countdownSeconds.toFloat(),
|
||||
onValueChange = { vm.updateCountdownSeconds(it.roundToInt()) },
|
||||
valueRange = 0f..10f,
|
||||
steps = 9,
|
||||
)
|
||||
|
||||
Text("Default mode", color = MaterialTheme.colorScheme.onBackground)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SessionMode.values().forEach { mode ->
|
||||
OutlinedButton(onClick = { vm.updateDefaultMode(mode) }) { Text(mode.name) }
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance)
|
||||
Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = ui.settings.showImmersiveProgressBar, onCheckedChange = vm::updateShowImmersiveProgressBar)
|
||||
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", 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.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
|
||||
@@ -19,8 +18,10 @@ class SettingsRepository(private val context: Context) {
|
||||
val safetyAcknowledged = booleanPreferencesKey("safety_ack")
|
||||
val safetyVersion = intPreferencesKey("safety_version")
|
||||
val countdownPref = stringPreferencesKey("countdown_pref")
|
||||
val countdownSeconds = intPreferencesKey("countdown_seconds")
|
||||
val defaultMode = stringPreferencesKey("default_mode")
|
||||
val showGuidance = booleanPreferencesKey("show_guidance")
|
||||
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
|
||||
val lastPresetId = stringPreferencesKey("last_preset")
|
||||
}
|
||||
|
||||
@@ -28,9 +29,12 @@ class SettingsRepository(private val context: Context) {
|
||||
AppSettings(
|
||||
safetyAcknowledged = p[Keys.safetyAcknowledged] ?: false,
|
||||
safetyAcknowledgedVersion = p[Keys.safetyVersion] ?: 0,
|
||||
countdownPreference = runCatching { CountdownPreference.valueOf(p[Keys.countdownPref] ?: "FIVE") }.getOrDefault(CountdownPreference.FIVE),
|
||||
countdownSeconds = p[Keys.countdownSeconds]
|
||||
?: legacyCountdownStringToSeconds(p[Keys.countdownPref])
|
||||
?: 5,
|
||||
defaultModePreference = runCatching { SessionMode.valueOf(p[Keys.defaultMode] ?: "AUDIO_VISUAL") }.getOrDefault(SessionMode.AUDIO_VISUAL),
|
||||
showHolderGuidanceBeforeSession = p[Keys.showGuidance] ?: false,
|
||||
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
|
||||
lastPresetId = p[Keys.lastPresetId],
|
||||
)
|
||||
}
|
||||
@@ -42,8 +46,19 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateCountdown(value: CountdownPreference) = context.dataStore.edit { it[Keys.countdownPref] = value.name }
|
||||
suspend fun updateCountdownSeconds(value: Int) = context.dataStore.edit {
|
||||
it[Keys.countdownSeconds] = value.coerceIn(0, 10)
|
||||
}
|
||||
|
||||
suspend fun updateDefaultMode(value: SessionMode) = context.dataStore.edit { it[Keys.defaultMode] = value.name }
|
||||
suspend fun updateGuidance(value: Boolean) = context.dataStore.edit { it[Keys.showGuidance] = value }
|
||||
suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit { it[Keys.showImmersiveProgressBar] = value }
|
||||
suspend fun updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id }
|
||||
|
||||
private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) {
|
||||
"OFF" -> 0
|
||||
"FIVE" -> 5
|
||||
"TEN" -> 10
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -34,28 +33,54 @@ data class SessionConfig(
|
||||
data class AppSettings(
|
||||
val safetyAcknowledged: Boolean = false,
|
||||
val safetyAcknowledgedVersion: Int = 0,
|
||||
val countdownPreference: CountdownPreference = CountdownPreference.FIVE,
|
||||
val countdownSeconds: Int = 5,
|
||||
val defaultModePreference: SessionMode = SessionMode.AUDIO_VISUAL,
|
||||
val showHolderGuidanceBeforeSession: Boolean = false,
|
||||
val showImmersiveProgressBar: Boolean = true,
|
||||
val lastPresetId: String? = null,
|
||||
)
|
||||
|
||||
object Presets {
|
||||
val builtIn = listOf(
|
||||
SessionPreset(
|
||||
"relax", "Relax", "Gentle pulse, slower beat", 10 * 60,
|
||||
VisualPattern.PULSE, 167, 60, 200f, 6f,
|
||||
"deep_release", "Deep Release", "Slow descent into calm with a soft ending", 18 * 60,
|
||||
VisualPattern.PULSE, 260, 52, 180f, 4.5f,
|
||||
"Stop if discomfort occurs.", 1
|
||||
),
|
||||
SessionPreset(
|
||||
"focus", "Focus", "Steady flash, conservative alert beat", 15 * 60,
|
||||
VisualPattern.FLASH, 100, 65, 220f, 10f,
|
||||
"laser_focus", "Laser Focus", "Progressive ramp to sharp attention and steady drive", 16 * 60,
|
||||
VisualPattern.FLASH, 120, 66, 220f, 12f,
|
||||
"Use in a safe seated place only.", 2
|
||||
),
|
||||
SessionPreset(
|
||||
"sleep", "Sleep Prep", "Slow pulse, low intensity", 20 * 60,
|
||||
VisualPattern.PULSE, 333, 45, 180f, 3f,
|
||||
"Do not use while doing other activities.", 3
|
||||
"creative_drift", "Creative Drift", "Alternating intensity waves for idea exploration", 22 * 60,
|
||||
VisualPattern.PULSE, 190, 58, 205f, 8f,
|
||||
"Use only in a safe resting environment.", 3
|
||||
),
|
||||
SessionPreset(
|
||||
"reset_breath", "Reset Breath", "Gentle rhythmic arcs designed for breathing cadence", 12 * 60,
|
||||
VisualPattern.PULSE, 300, 46, 170f, 5.5f,
|
||||
"Do not use while driving or operating machinery.", 4
|
||||
),
|
||||
SessionPreset(
|
||||
"night_rampdown", "Night Rampdown", "Long taper from busy mind to pre-sleep stillness", 28 * 60,
|
||||
VisualPattern.PULSE, 360, 40, 160f, 2.8f,
|
||||
"Do not use while doing other activities.", 5
|
||||
),
|
||||
SessionPreset(
|
||||
"power_warmup", "Power Warmup", "Short energizing ladder for pre-work momentum", 9 * 60,
|
||||
VisualPattern.FLASH, 95, 68, 245f, 14f,
|
||||
"Use in a safe seated place only.", 6
|
||||
),
|
||||
SessionPreset(
|
||||
"body_scan", "Body Scan", "Balanced mid-tempo sweep with a centered finish", 20 * 60,
|
||||
VisualPattern.PULSE, 210, 50, 190f, 6.8f,
|
||||
"Stop immediately for dizziness or discomfort.", 7
|
||||
),
|
||||
SessionPreset(
|
||||
"storm_to_still", "Storm to Still", "Starts active, then settles into deep quiet", 24 * 60,
|
||||
VisualPattern.FLASH, 150, 61, 215f, 9f,
|
||||
"Use only in a safe resting environment.", 8
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,3 +5,9 @@ import com.mindmachine.mvp.domain.RuntimeState
|
||||
fun shouldShowActiveControlsByDefault(runtimeState: RuntimeState): Boolean {
|
||||
return runtimeState != RuntimeState.RUNNING && runtimeState != RuntimeState.COUNTDOWN
|
||||
}
|
||||
|
||||
fun sessionProgressFraction(durationSec: Int, remainingSec: Int): Float {
|
||||
val total = durationSec.coerceAtLeast(1)
|
||||
val elapsed = (total - remainingSec).coerceIn(0, total)
|
||||
return elapsed.toFloat() / total.toFloat()
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ fun shouldUseImmersiveFullscreen(isVisualMode: Boolean, runtimeState: RuntimeSta
|
||||
}
|
||||
|
||||
fun shouldRevealSystemBarsOnTap(isVisualMode: Boolean, runtimeState: RuntimeState): Boolean {
|
||||
return shouldUseImmersiveFullscreen(isVisualMode, runtimeState)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.mindmachine.mvp.data.SettingsRepository
|
||||
import com.mindmachine.mvp.data.UserProgramEntity
|
||||
import com.mindmachine.mvp.data.UserProgramRepository
|
||||
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
|
||||
@@ -59,15 +58,27 @@ class MainViewModel(
|
||||
|
||||
private var runJob: Job? = null
|
||||
private var elapsedBeforePauseSec: Float = 0f
|
||||
private var cachedUserPrograms: List<UserProgramEntity> = emptyList()
|
||||
private var cachedPrograms: List<UserProgramEntity> = emptyList()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
|
||||
settings to userPrograms
|
||||
}.collect { (settings, userPrograms) ->
|
||||
cachedUserPrograms = userPrograms
|
||||
val userPresets = userPrograms.map {
|
||||
val builtInPrograms = Presets.builtIn.map { preset ->
|
||||
userPrograms.firstOrNull { it.id == preset.id } ?: UserProgramEntity(
|
||||
id = preset.id,
|
||||
name = preset.name,
|
||||
description = preset.description,
|
||||
timeline = TimelineProgramFactory.fromPreset(preset),
|
||||
sortOrder = preset.sortOrder,
|
||||
)
|
||||
}
|
||||
val mergedPrograms = (builtInPrograms + userPrograms.filterNot { program -> Presets.builtIn.any { it.id == program.id } })
|
||||
.sortedBy { it.sortOrder }
|
||||
cachedPrograms = mergedPrograms
|
||||
|
||||
val allPresets = mergedPrograms.map {
|
||||
TimelineProgramFactory.toPreset(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
@@ -77,7 +88,6 @@ class MainViewModel(
|
||||
isUserProgram = true,
|
||||
)
|
||||
}
|
||||
val allPresets = (Presets.builtIn + userPresets).sortedBy { it.sortOrder }
|
||||
|
||||
_ui.update { current ->
|
||||
val selectedId = settings.lastPresetId ?: current.selectedPreset.id
|
||||
@@ -88,7 +98,7 @@ class MainViewModel(
|
||||
presets = allPresets,
|
||||
selectedPreset = selectedPreset,
|
||||
config = current.config.copy(mode = settings.defaultModePreference),
|
||||
timeline = if (sameSelection) current.timeline else timelineForPreset(selectedPreset, userPrograms),
|
||||
timeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +116,7 @@ class MainViewModel(
|
||||
it.copy(
|
||||
selectedPreset = preset,
|
||||
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
|
||||
timeline = timelineForPreset(preset, cachedUserPrograms),
|
||||
timeline = timelineForProgramId(preset.id, cachedPrograms),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
@@ -143,8 +153,8 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
val state = _ui.value
|
||||
val id = if (state.selectedPreset.isUserProgram) state.selectedPreset.id else "user-${UUID.randomUUID()}"
|
||||
val sortOrder = cachedUserPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder()
|
||||
val id = state.selectedPreset.id
|
||||
val sortOrder = cachedPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder()
|
||||
|
||||
userProgramRepository.upsert(
|
||||
UserProgramEntity(
|
||||
@@ -176,13 +186,13 @@ class MainViewModel(
|
||||
fun deleteProgram(id: String) = viewModelScope.launch {
|
||||
userProgramRepository.delete(id)
|
||||
if (_ui.value.selectedPreset.id == id) {
|
||||
val fallback = Presets.builtIn.first()
|
||||
val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first()
|
||||
settingsRepository.updateLastPreset(fallback.id)
|
||||
_ui.update {
|
||||
it.copy(
|
||||
selectedPreset = fallback,
|
||||
config = fallback.toConfig(it.settings.defaultModePreference),
|
||||
timeline = TimelineProgramFactory.fromPreset(fallback),
|
||||
timeline = timelineForProgramId(fallback.id, cachedPrograms),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
@@ -212,9 +222,10 @@ class MainViewModel(
|
||||
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 updateCountdownSeconds(seconds: Int) = viewModelScope.launch { settingsRepository.updateCountdownSeconds(seconds) }
|
||||
fun updateDefaultMode(mode: SessionMode) = viewModelScope.launch { settingsRepository.updateDefaultMode(mode) }
|
||||
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
|
||||
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
|
||||
|
||||
fun startSession() {
|
||||
val state = _ui.value
|
||||
@@ -281,7 +292,7 @@ class MainViewModel(
|
||||
val clampedStartSec = startElapsedSec.coerceIn(0f, durationSec.toFloat())
|
||||
|
||||
if (includeCountdown) {
|
||||
val count = _ui.value.settings.countdownPreference.seconds
|
||||
val count = _ui.value.settings.countdownSeconds.coerceIn(0, 10)
|
||||
if (count > 0) {
|
||||
for (i in count downTo 1) {
|
||||
_ui.update {
|
||||
@@ -358,14 +369,18 @@ class MainViewModel(
|
||||
return (total - state.remainingSec).coerceIn(0f, total)
|
||||
}
|
||||
|
||||
private fun timelineForPreset(preset: SessionPreset, userPrograms: List<UserProgramEntity>): TimelineEditorState {
|
||||
return userPrograms.firstOrNull { it.id == preset.id }?.timeline ?: TimelineProgramFactory.fromPreset(preset)
|
||||
private fun timelineForProgramId(programId: String, programs: List<UserProgramEntity>): TimelineEditorState {
|
||||
val fromSaved = programs.firstOrNull { it.id == programId }?.timeline
|
||||
if (fromSaved != null) return fromSaved
|
||||
|
||||
val builtInPreset = Presets.builtIn.firstOrNull { it.id == programId }
|
||||
return if (builtInPreset != null) TimelineProgramFactory.fromPreset(builtInPreset) else TimelineEditorState.default()
|
||||
}
|
||||
|
||||
private fun nextSortOrder(): Int = (cachedUserPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1
|
||||
private fun nextSortOrder(): Int = (cachedPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1
|
||||
|
||||
private fun nextUntitledName(): String {
|
||||
val names = cachedUserPrograms.map { it.name.lowercase() }.toSet()
|
||||
val names = cachedPrograms.map { it.name.lowercase() }.toSet()
|
||||
var i = 1
|
||||
while (true) {
|
||||
val candidate = "Custom $i"
|
||||
|
||||
@@ -409,7 +409,12 @@ internal fun TimelineGraph(
|
||||
val curveId = draggingCurveId
|
||||
if (pointIndex != null && curveId != null) {
|
||||
val targetCurve = if (curveId == leftId) curveLeft else curveRight
|
||||
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, primary.position.y, h)
|
||||
val constrainedY = constrainYForCurve(
|
||||
y = primary.position.y,
|
||||
height = h,
|
||||
curveId = curveId,
|
||||
)
|
||||
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, constrainedY, h)
|
||||
if (curveId == leftId) curveLeft = updated else curveRight = updated
|
||||
currentOnUpdateCurve(curveId, updated)
|
||||
onCurveEditedHaptic()
|
||||
@@ -576,3 +581,22 @@ private fun valueToY(value01: Float, height: Float): Float {
|
||||
val v = value01.coerceIn(0f, 1f)
|
||||
return (1f - v) * height
|
||||
}
|
||||
|
||||
private fun constrainYForCurve(y: Float, height: Float, curveId: ActiveCurve): Float {
|
||||
val clampedY = y.coerceIn(0f, height)
|
||||
val value01 = (1f - (clampedY / height)).coerceIn(0f, 1f)
|
||||
|
||||
return when (curveId) {
|
||||
ActiveCurve.AUDIO_LEFT -> {
|
||||
val hz = ParameterMapping.logMap01(value01, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
|
||||
val bounded01 = ParameterMapping.invLog01(hz, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
|
||||
valueToY(bounded01, height)
|
||||
}
|
||||
ActiveCurve.AUDIO_RIGHT -> {
|
||||
val hz = ParameterMapping.logMap01(value01, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)
|
||||
val bounded01 = ParameterMapping.invLog01(hz, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)
|
||||
valueToY(bounded01, height)
|
||||
}
|
||||
else -> clampedY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,39 +5,11 @@ import com.mindmachine.mvp.domain.VisualPattern
|
||||
|
||||
object TimelineProgramFactory {
|
||||
fun fromPreset(preset: SessionPreset): TimelineEditorState {
|
||||
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
|
||||
|
||||
val flash01 = ParameterMapping.invLog01(
|
||||
value = preset.flashIntervalMs.toFloat(),
|
||||
min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
|
||||
max = ParameterRanges.FLASH_INTERVAL_MS_MAX,
|
||||
)
|
||||
val blank01 = flash01
|
||||
val carrier01 = ParameterMapping.invLog01(
|
||||
value = preset.carrierFrequencyHz,
|
||||
min = ParameterRanges.CARRIER_HZ_MIN,
|
||||
max = ParameterRanges.CARRIER_HZ_MAX,
|
||||
)
|
||||
val binaural01 = ParameterMapping.invLog01(
|
||||
value = preset.binauralDifferenceHz,
|
||||
min = ParameterRanges.BINAURAL_HZ_MIN,
|
||||
max = ParameterRanges.BINAURAL_HZ_MAX,
|
||||
)
|
||||
|
||||
return TimelineEditorState(
|
||||
durationSec = dur,
|
||||
selection = TimelineSelection(0, dur),
|
||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
|
||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||
visualLeft = TimelineCurve.constant(flash01, dur),
|
||||
visualRight = TimelineCurve.constant(blank01, dur),
|
||||
audioLeft = TimelineCurve.constant(carrier01, dur),
|
||||
audioRight = TimelineCurve.constant(binaural01, dur),
|
||||
isPlaying = false,
|
||||
playheadSec = 0f,
|
||||
)
|
||||
return builtInTimelineForPresetId(preset.id) ?: constantTimelineFromPreset(preset)
|
||||
}
|
||||
|
||||
fun builtInTimelineForPresetId(id: String): TimelineEditorState? = builtInPrograms[id]
|
||||
|
||||
fun toPreset(
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -78,4 +50,146 @@ object TimelineProgramFactory {
|
||||
isUserProgram = isUserProgram,
|
||||
)
|
||||
}
|
||||
|
||||
private fun constantTimelineFromPreset(preset: SessionPreset): TimelineEditorState {
|
||||
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
|
||||
|
||||
val flash01 = ParameterMapping.invLog01(
|
||||
value = preset.flashIntervalMs.toFloat(),
|
||||
min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
|
||||
max = ParameterRanges.FLASH_INTERVAL_MS_MAX,
|
||||
)
|
||||
val blank01 = flash01
|
||||
val carrier01 = ParameterMapping.invLog01(
|
||||
value = preset.carrierFrequencyHz,
|
||||
min = ParameterRanges.CARRIER_HZ_MIN,
|
||||
max = ParameterRanges.CARRIER_HZ_MAX,
|
||||
)
|
||||
val binaural01 = ParameterMapping.invLog01(
|
||||
value = preset.binauralDifferenceHz,
|
||||
min = ParameterRanges.BINAURAL_HZ_MIN,
|
||||
max = ParameterRanges.BINAURAL_HZ_MAX,
|
||||
)
|
||||
|
||||
return TimelineEditorState(
|
||||
durationSec = dur,
|
||||
selection = TimelineSelection(0, dur),
|
||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
|
||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||
visualLeft = TimelineCurve.constant(flash01, dur),
|
||||
visualRight = TimelineCurve.constant(blank01, dur),
|
||||
audioLeft = TimelineCurve.constant(carrier01, dur),
|
||||
audioRight = TimelineCurve.constant(binaural01, dur),
|
||||
isPlaying = false,
|
||||
playheadSec = 0f,
|
||||
)
|
||||
}
|
||||
|
||||
private fun timeline(
|
||||
durationSec: Int,
|
||||
visualLeftMs: List<Int>,
|
||||
visualRightMs: List<Int>,
|
||||
audioLeftHz: List<Float>,
|
||||
audioRightHz: List<Float>,
|
||||
): TimelineEditorState {
|
||||
val minutes = durationSec / 60
|
||||
require(visualLeftMs.size == minutes + 1)
|
||||
require(visualRightMs.size == minutes + 1)
|
||||
require(audioLeftHz.size == minutes + 1)
|
||||
require(audioRightHz.size == minutes + 1)
|
||||
|
||||
fun curveFromMs(values: List<Int>): TimelineCurve = TimelineCurve(
|
||||
values.mapIndexed { index, value ->
|
||||
TimelinePoint(
|
||||
tSec = index * 60,
|
||||
value01 = ParameterMapping.invLog01(
|
||||
value = value.toFloat(),
|
||||
min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
|
||||
max = ParameterRanges.FLASH_INTERVAL_MS_MAX,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
fun curveFromHz(values: List<Float>, min: Float, max: Float): TimelineCurve = TimelineCurve(
|
||||
values.mapIndexed { index, value ->
|
||||
TimelinePoint(
|
||||
tSec = index * 60,
|
||||
value01 = ParameterMapping.invLog01(value, min, max)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return TimelineEditorState(
|
||||
durationSec = durationSec,
|
||||
selection = TimelineSelection(0, durationSec),
|
||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
|
||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||
visualLeft = curveFromMs(visualLeftMs),
|
||||
visualRight = curveFromMs(visualRightMs),
|
||||
audioLeft = curveFromHz(audioLeftHz, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX),
|
||||
audioRight = curveFromHz(audioRightHz, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX),
|
||||
isPlaying = false,
|
||||
playheadSec = 0f,
|
||||
)
|
||||
}
|
||||
|
||||
private val builtInPrograms: Map<String, TimelineEditorState> = mapOf(
|
||||
"deep_release" to timeline(
|
||||
durationSec = 18 * 60,
|
||||
visualLeftMs = listOf(320, 300, 280, 260, 250, 240, 230, 220, 210, 200, 210, 220, 240, 260, 280, 300, 320, 340, 360),
|
||||
visualRightMs = listOf(360, 340, 320, 300, 280, 270, 260, 250, 240, 230, 240, 250, 270, 290, 310, 330, 350, 370, 390),
|
||||
audioLeftHz = listOf(190f, 188f, 186f, 184f, 182f, 180f, 178f, 176f, 174f, 172f, 170f, 168f, 166f, 164f, 162f, 160f, 158f, 156f, 154f),
|
||||
audioRightHz = listOf(7f, 6.6f, 6.2f, 5.8f, 5.4f, 5f, 4.8f, 4.6f, 4.4f, 4.2f, 4f, 3.8f, 3.6f, 3.4f, 3.2f, 3f, 2.8f, 2.6f, 2.4f),
|
||||
),
|
||||
"laser_focus" to timeline(
|
||||
durationSec = 16 * 60,
|
||||
visualLeftMs = listOf(180, 170, 160, 150, 140, 130, 120, 110, 100, 95, 95, 95, 100, 105, 110, 115, 120),
|
||||
visualRightMs = listOf(200, 190, 180, 170, 160, 150, 140, 130, 120, 115, 115, 115, 120, 125, 130, 135, 140),
|
||||
audioLeftHz = listOf(205f, 208f, 212f, 216f, 220f, 224f, 228f, 232f, 236f, 240f, 242f, 244f, 246f, 248f, 250f, 248f, 245f),
|
||||
audioRightHz = listOf(8f, 8.5f, 9f, 9.5f, 10f, 10.5f, 11f, 11.5f, 12f, 12f, 11.5f, 11f, 10.5f, 10f, 9.5f, 9f, 8.5f),
|
||||
),
|
||||
"creative_drift" to timeline(
|
||||
durationSec = 22 * 60,
|
||||
visualLeftMs = listOf(220, 200, 180, 160, 180, 210, 240, 270, 250, 220, 190, 170, 190, 220, 250, 280, 260, 230, 200, 180, 200, 230, 260),
|
||||
visualRightMs = listOf(260, 240, 220, 200, 220, 250, 280, 310, 290, 260, 230, 210, 230, 260, 290, 320, 300, 270, 240, 220, 240, 270, 300),
|
||||
audioLeftHz = listOf(200f, 204f, 208f, 212f, 216f, 220f, 224f, 228f, 224f, 220f, 216f, 212f, 208f, 204f, 200f, 196f, 192f, 196f, 200f, 204f, 208f, 212f, 216f),
|
||||
audioRightHz = listOf(6f, 6.8f, 7.6f, 8.4f, 9.2f, 10f, 9.4f, 8.8f, 8.2f, 7.6f, 7f, 6.4f, 5.8f, 5.2f, 4.6f, 4f, 4.8f, 5.6f, 6.4f, 7.2f, 8f, 8.8f, 9.6f),
|
||||
),
|
||||
"reset_breath" to timeline(
|
||||
durationSec = 12 * 60,
|
||||
visualLeftMs = listOf(360, 340, 320, 300, 280, 260, 240, 260, 280, 300, 320, 340, 360),
|
||||
visualRightMs = listOf(390, 370, 350, 330, 310, 290, 270, 290, 310, 330, 350, 370, 390),
|
||||
audioLeftHz = listOf(175f, 173f, 171f, 169f, 167f, 165f, 163f, 165f, 167f, 169f, 171f, 173f, 175f),
|
||||
audioRightHz = listOf(6.5f, 6.2f, 5.9f, 5.6f, 5.3f, 5f, 4.7f, 5f, 5.3f, 5.6f, 5.9f, 6.2f, 6.5f),
|
||||
),
|
||||
"night_rampdown" to timeline(
|
||||
durationSec = 28 * 60,
|
||||
visualLeftMs = listOf(300, 290, 280, 270, 260, 250, 240, 230, 220, 210, 205, 200, 205, 210, 220, 230, 240, 250, 260, 270, 280, 290, 300, 320, 340, 360, 380, 400, 420),
|
||||
visualRightMs = listOf(330, 320, 310, 300, 290, 280, 270, 260, 250, 240, 235, 230, 235, 240, 250, 260, 270, 280, 290, 300, 310, 320, 330, 350, 370, 390, 410, 430, 450),
|
||||
audioLeftHz = listOf(182f, 181f, 180f, 179f, 178f, 177f, 176f, 175f, 174f, 173f, 172f, 171f, 170f, 169f, 168f, 167f, 166f, 165f, 164f, 163f, 162f, 161f, 160f, 159f, 158f, 157f, 156f, 155f, 154f),
|
||||
audioRightHz = listOf(5f, 4.9f, 4.8f, 4.7f, 4.6f, 4.5f, 4.4f, 4.3f, 4.2f, 4.1f, 4f, 3.9f, 3.8f, 3.7f, 3.6f, 3.5f, 3.4f, 3.3f, 3.2f, 3.1f, 3f, 2.9f, 2.8f, 2.7f, 2.6f, 2.5f, 2.4f, 2.3f, 2.2f),
|
||||
),
|
||||
"power_warmup" to timeline(
|
||||
durationSec = 9 * 60,
|
||||
visualLeftMs = listOf(140, 130, 120, 110, 100, 90, 85, 90, 100, 115),
|
||||
visualRightMs = listOf(160, 150, 140, 130, 120, 110, 105, 110, 120, 135),
|
||||
audioLeftHz = listOf(220f, 225f, 230f, 235f, 240f, 245f, 250f, 248f, 244f, 238f),
|
||||
audioRightHz = listOf(10f, 11f, 12f, 13f, 14f, 15f, 14f, 13f, 12f, 11f),
|
||||
),
|
||||
"body_scan" to timeline(
|
||||
durationSec = 20 * 60,
|
||||
visualLeftMs = listOf(260, 250, 240, 230, 220, 210, 200, 190, 200, 210, 220, 230, 240, 250, 260, 250, 240, 230, 220, 210, 200),
|
||||
visualRightMs = listOf(290, 280, 270, 260, 250, 240, 230, 220, 230, 240, 250, 260, 270, 280, 290, 280, 270, 260, 250, 240, 230),
|
||||
audioLeftHz = listOf(195f, 194f, 193f, 192f, 191f, 190f, 189f, 188f, 187f, 186f, 185f, 186f, 187f, 188f, 189f, 190f, 191f, 192f, 193f, 194f, 195f),
|
||||
audioRightHz = listOf(7.2f, 7f, 6.8f, 6.6f, 6.4f, 6.2f, 6f, 5.8f, 5.6f, 5.4f, 5.2f, 5.4f, 5.6f, 5.8f, 6f, 6.2f, 6.4f, 6.6f, 6.8f, 7f, 7.2f),
|
||||
),
|
||||
"storm_to_still" to timeline(
|
||||
durationSec = 24 * 60,
|
||||
visualLeftMs = listOf(140, 130, 120, 110, 100, 95, 90, 100, 120, 150, 180, 210, 240, 270, 300, 330, 340, 350, 360, 370, 380, 390, 400, 410, 420),
|
||||
visualRightMs = listOf(170, 160, 150, 140, 130, 125, 120, 130, 150, 180, 210, 240, 270, 300, 330, 360, 370, 380, 390, 400, 410, 420, 430, 440, 450),
|
||||
audioLeftHz = listOf(230f, 232f, 234f, 236f, 238f, 240f, 242f, 238f, 232f, 226f, 220f, 214f, 208f, 202f, 196f, 190f, 186f, 182f, 178f, 174f, 170f, 166f, 162f, 158f, 154f),
|
||||
audioRightHz = listOf(12f, 12.5f, 13f, 13.5f, 14f, 14.5f, 15f, 14f, 13f, 12f, 11f, 10f, 9f, 8f, 7f, 6f, 5.5f, 5f, 4.5f, 4f, 3.6f, 3.2f, 2.9f, 2.6f, 2.4f),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ object TimelineRuntimeEvaluator {
|
||||
return RuntimeParams(
|
||||
flashOnMs = flashOnMs.coerceIn(50, 2000),
|
||||
flashOffMs = flashOffMs.coerceIn(50, 2000),
|
||||
carrierHz = carrierHz,
|
||||
binauralHz = binauralHz,
|
||||
carrierHz = carrierHz.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX),
|
||||
binauralHz = binauralHz.coerceIn(ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.mindmachine.mvp
|
||||
|
||||
import com.mindmachine.mvp.domain.RuntimeState
|
||||
import com.mindmachine.mvp.session.sessionProgressFraction
|
||||
import com.mindmachine.mvp.session.shouldRevealSystemBarsOnTap
|
||||
import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault
|
||||
import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen
|
||||
@@ -36,12 +37,20 @@ class ActiveSessionUiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tap_to_reveal_system_bars_is_only_enabled_during_active_visual_stimulation() {
|
||||
assertTrue(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.COUNTDOWN))
|
||||
assertTrue(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.RUNNING))
|
||||
|
||||
fun tap_to_reveal_system_bars_is_disabled() {
|
||||
assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.COUNTDOWN))
|
||||
assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.RUNNING))
|
||||
assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = false, runtimeState = RuntimeState.RUNNING))
|
||||
assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.PAUSED))
|
||||
assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.COMPLETED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun session_progress_fraction_tracks_elapsed_time_and_clamps() {
|
||||
assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 600) == 0f)
|
||||
assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 300) == 0.5f)
|
||||
assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 0) == 1f)
|
||||
assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 800) == 0f)
|
||||
assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = -10) == 1f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,22 +7,47 @@ import org.junit.Test
|
||||
|
||||
class PresetDefaultsTest {
|
||||
@Test
|
||||
fun mvp_has_exactly_three_presets_with_spec_defaults() {
|
||||
assertEquals(3, Presets.builtIn.size)
|
||||
val relax = Presets.builtIn.first { it.id == "relax" }
|
||||
val focus = Presets.builtIn.first { it.id == "focus" }
|
||||
val sleep = Presets.builtIn.first { it.id == "sleep" }
|
||||
fun mvp_has_eight_preset_programs_with_expected_defaults() {
|
||||
assertEquals(8, Presets.builtIn.size)
|
||||
val deepRelease = Presets.builtIn.first { it.id == "deep_release" }
|
||||
val laserFocus = Presets.builtIn.first { it.id == "laser_focus" }
|
||||
val creativeDrift = Presets.builtIn.first { it.id == "creative_drift" }
|
||||
val resetBreath = Presets.builtIn.first { it.id == "reset_breath" }
|
||||
val nightRampdown = Presets.builtIn.first { it.id == "night_rampdown" }
|
||||
val powerWarmup = Presets.builtIn.first { it.id == "power_warmup" }
|
||||
val bodyScan = Presets.builtIn.first { it.id == "body_scan" }
|
||||
val stormToStill = Presets.builtIn.first { it.id == "storm_to_still" }
|
||||
|
||||
assertEquals(10 * 60, relax.defaultDurationSec)
|
||||
assertEquals(VisualPattern.PULSE, relax.visualPatternType)
|
||||
assertEquals(167, relax.flashIntervalMs)
|
||||
assertEquals(18 * 60, deepRelease.defaultDurationSec)
|
||||
assertEquals(VisualPattern.PULSE, deepRelease.visualPatternType)
|
||||
assertEquals(260, deepRelease.flashIntervalMs)
|
||||
|
||||
assertEquals(15 * 60, focus.defaultDurationSec)
|
||||
assertEquals(VisualPattern.FLASH, focus.visualPatternType)
|
||||
assertEquals(100, focus.flashIntervalMs)
|
||||
assertEquals(16 * 60, laserFocus.defaultDurationSec)
|
||||
assertEquals(VisualPattern.FLASH, laserFocus.visualPatternType)
|
||||
assertEquals(120, laserFocus.flashIntervalMs)
|
||||
|
||||
assertEquals(20 * 60, sleep.defaultDurationSec)
|
||||
assertEquals(VisualPattern.PULSE, sleep.visualPatternType)
|
||||
assertEquals(333, sleep.flashIntervalMs)
|
||||
assertEquals(22 * 60, creativeDrift.defaultDurationSec)
|
||||
assertEquals(VisualPattern.PULSE, creativeDrift.visualPatternType)
|
||||
assertEquals(190, creativeDrift.flashIntervalMs)
|
||||
|
||||
assertEquals(12 * 60, resetBreath.defaultDurationSec)
|
||||
assertEquals(VisualPattern.PULSE, resetBreath.visualPatternType)
|
||||
assertEquals(300, resetBreath.flashIntervalMs)
|
||||
|
||||
assertEquals(28 * 60, nightRampdown.defaultDurationSec)
|
||||
assertEquals(VisualPattern.PULSE, nightRampdown.visualPatternType)
|
||||
assertEquals(360, nightRampdown.flashIntervalMs)
|
||||
|
||||
assertEquals(9 * 60, powerWarmup.defaultDurationSec)
|
||||
assertEquals(VisualPattern.FLASH, powerWarmup.visualPatternType)
|
||||
assertEquals(95, powerWarmup.flashIntervalMs)
|
||||
|
||||
assertEquals(20 * 60, bodyScan.defaultDurationSec)
|
||||
assertEquals(VisualPattern.PULSE, bodyScan.visualPatternType)
|
||||
assertEquals(210, bodyScan.flashIntervalMs)
|
||||
|
||||
assertEquals(24 * 60, stormToStill.defaultDurationSec)
|
||||
assertEquals(VisualPattern.FLASH, stormToStill.visualPatternType)
|
||||
assertEquals(150, stormToStill.flashIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user