From 5c48387dc2ddbc1c46ea0bb70f22b42fa69fbb4f Mon Sep 17 00:00:00 2001 From: Tretzi Date: Sat, 21 Mar 2026 22:01:01 -0500 Subject: [PATCH] Auto commit Sat Mar 21 10:01:01 PM CDT 2026 --- .../java/com/mindmachine/mvp/MainActivity.kt | 48 ++- .../mvp/data/UserProgramRepository.kt | 144 +++++++++ .../java/com/mindmachine/mvp/domain/Models.kt | 1 + .../mindmachine/mvp/session/MainViewModel.kt | 138 ++++++++- .../mvp/session/TimelineEditorScreen.kt | 275 +++++++++--------- .../mvp/session/TimelineProgramFactory.kt | 42 +++ 6 files changed, 495 insertions(+), 153 deletions(-) create mode 100644 app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index ef17494..8bf8ca0 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable @@ -77,6 +78,7 @@ import androidx.navigation.compose.rememberNavController 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 @@ -109,7 +111,12 @@ private val MindMachineColors = darkColorScheme( class MainActivity : ComponentActivity() { private val vm: MainViewModel by viewModels { - MainViewModel.Factory(SettingsRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine()) + MainViewModel.Factory( + SettingsRepository(applicationContext), + UserProgramRepository(applicationContext), + HeadsetMonitor(applicationContext), + BinauralAudioEngine(), + ) } override fun onCreate(savedInstanceState: Bundle?) { @@ -158,6 +165,10 @@ fun App(vm: MainViewModel = viewModel()) { composable("home") { Column(Modifier.fillMaxSize()) { TopAppBar(title = { Text("MindMachine") }, actions = { + TextButton(onClick = { + vm.createNewProgramDraft() + nav.navigate("setup") + }) { Text("Add") } TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") } }) LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -175,10 +186,19 @@ fun App(vm: MainViewModel = viewModel()) { 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} • binaural ${p.binauralDifferenceHz}") + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(p.name, style = MaterialTheme.typography.titleMedium) + Text(p.description) + Text("${p.defaultDurationSec / 60} min • ${p.visualPatternType} • binaural ${p.binauralDifferenceHz}") + } + if (p.isUserProgram) { + TextButton(onClick = { vm.deleteProgram(p.id) }) { Text("Delete") } + } } } } @@ -270,6 +290,8 @@ fun SetupScreen( .safeDrawingPadding() .padding(horizontal = 16.dp, vertical = 12.dp) + var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) } + Column( modifier = containerModifier, verticalArrangement = Arrangement.spacedBy(14.dp), @@ -280,6 +302,13 @@ fun SetupScreen( color = MaterialTheme.colorScheme.onBackground, ) + TextField( + value = programName, + onValueChange = { programName = it }, + label = { Text("Program name") }, + modifier = Modifier.fillMaxWidth(), + ) + DurationSliderCard( durationSec = ui.timeline.durationSec, onDurationSecChanged = { vm.setDurationSec(it) }, @@ -292,8 +321,13 @@ fun SetupScreen( ) TextButton(onClick = onHolder) { Text("Holder Guidance") } - if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error) - Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) { + Text("Save") + } + Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") } + } + if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error) Spacer(Modifier.height(8.dp)) } } diff --git a/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt b/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt new file mode 100644 index 0000000..0b1dd50 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt @@ -0,0 +1,144 @@ +package com.mindmachine.mvp.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.mindmachine.mvp.session.TimelineCurve +import com.mindmachine.mvp.session.TimelineEditorState +import com.mindmachine.mvp.session.TimelinePoint +import com.mindmachine.mvp.session.TimelineSelection +import com.mindmachine.mvp.session.TimelineViewport +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.json.JSONArray +import org.json.JSONObject + +private val Context.programDataStore by preferencesDataStore("mindmachine_programs") + +class UserProgramRepository(private val context: Context) { + private object Keys { + val programsJson = stringPreferencesKey("user_programs_json") + } + + val userPrograms: Flow> = context.programDataStore.data.map { prefs -> + parsePrograms(prefs[Keys.programsJson]) + } + + suspend fun upsert(program: UserProgramEntity) { + context.programDataStore.edit { prefs -> + val current = parsePrograms(prefs[Keys.programsJson]).toMutableList() + val idx = current.indexOfFirst { it.id == program.id } + if (idx >= 0) { + current[idx] = program + } else { + current += program + } + prefs[Keys.programsJson] = toJson(current) + } + } + + suspend fun delete(id: String) { + context.programDataStore.edit { prefs -> + val current = parsePrograms(prefs[Keys.programsJson]).filterNot { it.id == id } + prefs[Keys.programsJson] = toJson(current) + } + } + + private fun parsePrograms(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return runCatching { + val arr = JSONArray(raw) + buildList { + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + add( + UserProgramEntity( + id = o.getString("id"), + name = o.getString("name"), + description = o.optString("description", "Custom program"), + timeline = parseTimeline(o.getJSONObject("timeline")), + sortOrder = o.optInt("sortOrder", i + 1000), + ) + ) + } + } + }.getOrDefault(emptyList()) + } + + private fun parseTimeline(o: JSONObject): TimelineEditorState { + fun parseCurve(name: String): TimelineCurve { + val points = o.getJSONArray(name) + return TimelineCurve( + points = buildList { + for (i in 0 until points.length()) { + val p = points.getJSONObject(i) + add(TimelinePoint(tSec = p.getInt("t"), value01 = p.getDouble("v").toFloat())) + } + } + ) + } + + return TimelineEditorState( + durationSec = o.getInt("durationSec"), + selection = TimelineSelection( + startSec = o.optInt("selectionStartSec", 0), + endSec = o.optInt("selectionEndSec", o.getInt("durationSec")), + ), + viewport = TimelineViewport( + startSec = o.optDouble("viewportStartSec", 0.0).toFloat(), + secondsPerScreen = o.optDouble("viewportSecondsPerScreen", 600.0).toFloat(), + ), + activeCurve = TimelineEditorState.ActiveCurve.valueOf(o.optString("activeCurve", TimelineEditorState.ActiveCurve.VISUAL_LEFT.name)), + visualLeft = parseCurve("visualLeft"), + visualRight = parseCurve("visualRight"), + audioLeft = parseCurve("audioLeft"), + audioRight = parseCurve("audioRight"), + isPlaying = false, + playheadSec = 0f, + ).setDurationSec(o.getInt("durationSec")) + } + + private fun toJson(programs: List): String { + val arr = JSONArray() + programs.sortedBy { it.sortOrder }.forEach { program -> + val o = JSONObject() + o.put("id", program.id) + o.put("name", program.name) + o.put("description", program.description) + o.put("sortOrder", program.sortOrder) + + val timeline = JSONObject() + timeline.put("durationSec", program.timeline.durationSec) + timeline.put("selectionStartSec", program.timeline.selection.startSec) + timeline.put("selectionEndSec", program.timeline.selection.endSec) + timeline.put("viewportStartSec", program.timeline.viewport.startSec) + timeline.put("viewportSecondsPerScreen", program.timeline.viewport.secondsPerScreen) + timeline.put("activeCurve", program.timeline.activeCurve.name) + timeline.put("visualLeft", pointsArray(program.timeline.visualLeft)) + timeline.put("visualRight", pointsArray(program.timeline.visualRight)) + timeline.put("audioLeft", pointsArray(program.timeline.audioLeft)) + timeline.put("audioRight", pointsArray(program.timeline.audioRight)) + o.put("timeline", timeline) + + arr.put(o) + } + return arr.toString() + } + + private fun pointsArray(curve: TimelineCurve): JSONArray { + val arr = JSONArray() + curve.points.forEach { p -> + arr.put(JSONObject().put("t", p.tSec).put("v", p.value01.toDouble())) + } + return arr + } +} + +data class UserProgramEntity( + val id: String, + val name: String, + val description: String, + val timeline: TimelineEditorState, + val sortOrder: Int, +) diff --git a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt index fdf12e4..5ea9610 100644 --- a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt +++ b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt @@ -17,6 +17,7 @@ data class SessionPreset( val binauralDifferenceHz: Float, val cautionNote: String, val sortOrder: Int, + val isUserProgram: Boolean = false, ) data class SessionConfig( diff --git a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt index 63cc80c..7d03cff 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -6,6 +6,8 @@ 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.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 @@ -19,8 +21,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID const val SAFETY_VERSION = 1 @@ -46,6 +50,7 @@ data class UiState( class MainViewModel( private val settingsRepository: SettingsRepository, + private val userProgramRepository: UserProgramRepository, private val headsetMonitor: HeadsetMonitor, private val audioEngine: BinauralAudioEngine, ) : ViewModel() { @@ -54,34 +59,136 @@ class MainViewModel( private var runJob: Job? = null private var elapsedBeforePauseSec: Float = 0f + private var cachedUserPrograms: List = emptyList() init { viewModelScope.launch { - settingsRepository.settings.collect { s -> + combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms -> + settings to userPrograms + }.collect { (settings, userPrograms) -> + cachedUserPrograms = userPrograms + val userPresets = userPrograms.map { + TimelineProgramFactory.toPreset( + id = it.id, + name = it.name, + description = it.description, + timeline = it.timeline, + sortOrder = it.sortOrder, + isUserProgram = true, + ) + } + val allPresets = (Presets.builtIn + userPresets).sortedBy { it.sortOrder } + _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)) + val selectedId = settings.lastPresetId ?: current.selectedPreset.id + val selectedPreset = allPresets.find { it.id == selectedId } ?: allPresets.first() + val sameSelection = selectedPreset.id == current.selectedPreset.id + current.copy( + settings = settings, + presets = allPresets, + selectedPreset = selectedPreset, + config = current.config.copy(mode = settings.defaultModePreference), + timeline = if (sameSelection) current.timeline else timelineForPreset(selectedPreset, userPrograms), + ) } } } } fun acknowledgeSafety() = viewModelScope.launch { settingsRepository.acknowledgeSafety(SAFETY_VERSION) } + fun choosePreset(id: String) = viewModelScope.launch { - val preset = _ui.value.presets.first { it.id == id } + val state = _ui.value + val preset = state.presets.first { it.id == id } settingsRepository.updateLastPreset(id) _ui.update { val cfg = preset.toConfig(it.settings.defaultModePreference) it.copy( selectedPreset = preset, config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)), - timeline = TimelineProgramFactory.fromPreset(preset), + timeline = timelineForPreset(preset, cachedUserPrograms), error = null, ) } elapsedBeforePauseSec = 0f } + fun createNewProgramDraft() { + val draftId = "user-${UUID.randomUUID()}" + val displayName = nextUntitledName() + val timeline = TimelineEditorState.default(20 * 60) + val preset = TimelineProgramFactory.toPreset( + id = draftId, + name = displayName, + description = "Custom program", + timeline = timeline, + sortOrder = nextSortOrder(), + isUserProgram = true, + ) + _ui.update { + it.copy( + selectedPreset = preset, + config = preset.toConfig(it.settings.defaultModePreference), + timeline = timeline, + error = null, + ) + } + } + + fun saveCurrentProgram(name: String) = viewModelScope.launch { + val trimmed = name.trim() + if (trimmed.isBlank()) { + _ui.update { it.copy(error = "Enter a name before saving.") } + return@launch + } + + 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() + + userProgramRepository.upsert( + UserProgramEntity( + id = id, + name = trimmed, + description = "Custom program", + timeline = state.timeline, + sortOrder = sortOrder, + ) + ) + settingsRepository.updateLastPreset(id) + + _ui.update { + val savedPreset = TimelineProgramFactory.toPreset( + id = id, + name = trimmed, + description = "Custom program", + timeline = it.timeline, + sortOrder = sortOrder, + isUserProgram = true, + ) + it.copy( + selectedPreset = savedPreset, + error = "Saved \"$trimmed\"", + ) + } + } + + fun deleteProgram(id: String) = viewModelScope.launch { + userProgramRepository.delete(id) + if (_ui.value.selectedPreset.id == id) { + val fallback = Presets.builtIn.first() + settingsRepository.updateLastPreset(fallback.id) + _ui.update { + it.copy( + selectedPreset = fallback, + config = fallback.toConfig(it.settings.defaultModePreference), + timeline = TimelineProgramFactory.fromPreset(fallback), + error = null, + ) + } + } + } + fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) } fun setDurationSec(seconds: Int) = _ui.update { @@ -101,7 +208,6 @@ class MainViewModel( ) } - // Legacy fixed-parameter setters kept for now but no longer used by Setup. fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) } fun setCarrier(value: Float) = _ui.update { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), error = null) } fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) } @@ -252,11 +358,29 @@ class MainViewModel( return (total - state.remainingSec).coerceIn(0f, total) } + private fun timelineForPreset(preset: SessionPreset, userPrograms: List): TimelineEditorState { + return userPrograms.firstOrNull { it.id == preset.id }?.timeline ?: TimelineProgramFactory.fromPreset(preset) + } + + private fun nextSortOrder(): Int = (cachedUserPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1 + + private fun nextUntitledName(): String { + val names = cachedUserPrograms.map { it.name.lowercase() }.toSet() + var i = 1 + while (true) { + val candidate = "Custom $i" + if (candidate.lowercase() !in names) return candidate + i++ + } + } + class Factory( private val settingsRepository: SettingsRepository, + private val userProgramRepository: UserProgramRepository, private val headsetMonitor: HeadsetMonitor, private val audioEngine: BinauralAudioEngine, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T = MainViewModel(settingsRepository, headsetMonitor, audioEngine) as T + override fun create(modelClass: Class): T = + MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine) as T } } diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt index b25f330..7f942cd 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -19,7 +19,6 @@ 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.layout.weight import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -321,169 +320,167 @@ internal fun TimelineGraph( var curveRight = currentCurveRight val w = size.width.toFloat() val h = size.height.toFloat() - val startX = viewport.timeToX(selection.startSec, w) - val endX = viewport.timeToX(selection.endSec, w) val hitPx = 32f - var activePointerId = down.id - var lastPos = down.position - var isTransform = false - var moved = false + var activePointerId = down.id + var lastPos = down.position + var isTransform = false + var moved = false - val hits = buildList { - curveLeft.hitTestPoint(down.position, viewport, w, h)?.let { idx -> - val point = curveLeft.points[idx] - val x = viewport.timeToX(point.tSec, w) - val y = valueToY(point.value01, h) - val dx = down.position.x - x - val dy = down.position.y - y - add(Triple(leftId, idx, dx * dx + dy * dy)) + val startX = viewport.timeToX(selection.startSec, w) + val endX = viewport.timeToX(selection.endSec, w) + val hits = buildList { + curveLeft.hitTestPoint(down.position, viewport, w, h)?.let { idx -> + val point = curveLeft.points[idx] + val x = viewport.timeToX(point.tSec, w) + val y = valueToY(point.value01, h) + val dx = down.position.x - x + val dy = down.position.y - y + add(Triple(leftId, idx, dx * dx + dy * dy)) + } + curveRight.hitTestPoint(down.position, viewport, w, h)?.let { idx -> + val point = curveRight.points[idx] + val x = viewport.timeToX(point.tSec, w) + val y = valueToY(point.value01, h) + val dx = down.position.x - x + val dy = down.position.y - y + add(Triple(rightId, idx, dx * dx + dy * dy)) + } } - curveRight.hitTestPoint(down.position, viewport, w, h)?.let { idx -> - val point = curveRight.points[idx] - val x = viewport.timeToX(point.tSec, w) - val y = valueToY(point.value01, h) - val dx = down.position.x - x - val dy = down.position.y - y - add(Triple(rightId, idx, dx * dx + dy * dy)) - } - } - val nearestHit = hits.minByOrNull { it.third } - if (nearestHit != null) { - draggingCurveId = nearestHit.first - draggingPointIndex = nearestHit.second - dragMode = HandleDrag.NONE - currentOnTapSideSelect(if (nearestHit.first == leftId) Side.LEFT else Side.RIGHT) - } else { - draggingPointIndex = null - draggingCurveId = null - dragMode = when { - abs(down.position.x - startX) <= hitPx -> HandleDrag.START - abs(down.position.x - endX) <= hitPx -> HandleDrag.END - else -> HandleDrag.NONE - } - } - - while (true) { - val event = awaitPointerEvent(pass = PointerEventPass.Initial) - val pressed = event.changes.filter { it.pressed } - if (pressed.isEmpty()) break - - if (pressed.size >= 2) { - isTransform = true + val nearestHit = hits.minByOrNull { it.third } + if (nearestHit != null) { + draggingCurveId = nearestHit.first + draggingPointIndex = nearestHit.second + dragMode = HandleDrag.NONE + currentOnTapSideSelect(if (nearestHit.first == leftId) Side.LEFT else Side.RIGHT) + } else { draggingPointIndex = null draggingCurveId = null - dragMode = HandleDrag.NONE - } else if (pressed.size == 1 && isTransform) { - // Single finger after multi-finger: treat as single-finger drag - isTransform = false + dragMode = when { + abs(down.position.x - startX) <= hitPx -> HandleDrag.START + abs(down.position.x - endX) <= hitPx -> HandleDrag.END + else -> HandleDrag.NONE + } } - if (isTransform) { - val zoom = event.calculateZoom() - val pan = event.calculatePan() - val centroid = event.calculateCentroid(useCurrent = true) - if (zoom != 1f || pan.x != 0f || pan.y != 0f) { - moved = true - val newViewport = viewport.applyZoomPan( - zoomChange = zoom, - panPx = pan.x, - widthPx = size.width.toFloat(), - centroidPx = centroid.x, - ) - viewport = newViewport - currentOnViewportChanged(newViewport) + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val pressed = event.changes.filter { it.pressed } + if (pressed.isEmpty()) break + + if (pressed.size >= 2) { + isTransform = true + draggingPointIndex = null + draggingCurveId = null + dragMode = HandleDrag.NONE + } else if (pressed.size == 1 && isTransform) { + isTransform = false + lastPos = pressed.first().position } - event.changes.forEach { it.consume() } - continue - } - val primary = pressed.firstOrNull { it.id == activePointerId } ?: pressed.first() - activePointerId = primary.id - val delta = primary.position - lastPos - lastPos = primary.position - - if (delta.getDistanceSquared() > 0.5f) moved = true - - val pointIndex = draggingPointIndex - 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) - if (curveId == leftId) curveLeft = updated else curveRight = updated - currentOnUpdateCurve(curveId, updated) - onCurveEditedHaptic() - primary.consume() - continue - } - - val t = viewport.xToTimeSec(primary.position.x, w).toInt().coerceAtLeast(0) - when (dragMode) { - HandleDrag.START -> { - val newSelection = TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec) - selection = newSelection - currentOnSelectionChanged(newSelection) - primary.consume() - } - HandleDrag.END -> { - val newSelection = TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60)) - selection = newSelection - currentOnSelectionChanged(newSelection) - primary.consume() - } - HandleDrag.NONE -> { - if (delta.x != 0f) { + if (isTransform) { + val zoom = event.calculateZoom() + val pan = event.calculatePan() + val centroid = event.calculateCentroid(useCurrent = true) + if (zoom != 1f || pan.x != 0f || pan.y != 0f) { + moved = true val newViewport = viewport.applyZoomPan( - zoomChange = 1f, - panPx = delta.x, + zoomChange = zoom, + panPx = pan.x, widthPx = w, - centroidPx = w / 2f, + centroidPx = centroid.x, ) viewport = newViewport currentOnViewportChanged(newViewport) + } + event.changes.forEach { it.consume() } + continue + } + + val primary = pressed.firstOrNull { it.id == activePointerId } ?: pressed.first() + activePointerId = primary.id + val delta = primary.position - lastPos + lastPos = primary.position + + if (delta.getDistanceSquared() > 0.5f) moved = true + + val pointIndex = draggingPointIndex + 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) + if (curveId == leftId) curveLeft = updated else curveRight = updated + currentOnUpdateCurve(curveId, updated) + onCurveEditedHaptic() + primary.consume() + continue + } + + val t = viewport.xToTimeSec(primary.position.x, w).toInt().coerceAtLeast(0) + when (dragMode) { + HandleDrag.START -> { + val newSelection = TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec) + selection = newSelection + currentOnSelectionChanged(newSelection) primary.consume() } + HandleDrag.END -> { + val newSelection = TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60)) + selection = newSelection + currentOnSelectionChanged(newSelection) + primary.consume() + } + HandleDrag.NONE -> { + if (delta.x != 0f) { + val newViewport = viewport.applyZoomPan( + zoomChange = 1f, + panPx = delta.x, + widthPx = w, + centroidPx = w / 2f, + ) + viewport = newViewport + currentOnViewportChanged(newViewport) + primary.consume() + } + } } } - } - if (!moved && !isTransform) { - val side = if (down.position.x < size.width / 2f) Side.LEFT else Side.RIGHT - currentOnTapSideSelect(side) + if (!moved && !isTransform) { + val side = if (down.position.x < size.width / 2f) Side.LEFT else Side.RIGHT + currentOnTapSideSelect(side) + } + draggingPointIndex = null + draggingCurveId = null + dragMode = HandleDrag.NONE } - draggingPointIndex = null - draggingCurveId = null - dragMode = HandleDrag.NONE } } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawGrid() + + val startX = viewport.timeToX(selection.startSec, size.width) + val endX = viewport.timeToX(selection.endSec, size.width) + drawRect( + color = Color.White.copy(alpha = 0.06f), + topLeft = Offset(min(startX, endX), 0f), + size = androidx.compose.ui.geometry.Size(abs(endX - startX), size.height) + ) + drawLine(Color.White.copy(alpha = 0.35f), Offset(startX, 0f), Offset(startX, size.height), strokeWidth = 3f) + drawLine(Color.White.copy(alpha = 0.35f), Offset(endX, 0f), Offset(endX, size.height), strokeWidth = 3f) + + val handleColor = Color.White.copy(alpha = 0.9f) + drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f)) + drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f)) + + val playX = viewport.timeToX(playheadSec.toInt(), size.width) + drawLine(Color(0xFFFFFFFF).copy(alpha = 0.6f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f) + + drawCurve(curveLeft, selected = selectedLeft, color = leftColor, viewport = viewport) + drawCurve(curveRight, selected = selectedRight, color = rightColor, viewport = viewport) } - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - drawGrid() - - val startX = viewport.timeToX(selection.startSec, size.width) - val endX = viewport.timeToX(selection.endSec, size.width) - drawRect( - color = Color.White.copy(alpha = 0.06f), - topLeft = Offset(min(startX, endX), 0f), - size = androidx.compose.ui.geometry.Size(abs(endX - startX), size.height) - ) - drawLine(Color.White.copy(alpha = 0.35f), Offset(startX, 0f), Offset(startX, size.height), strokeWidth = 3f) - drawLine(Color.White.copy(alpha = 0.35f), Offset(endX, 0f), Offset(endX, size.height), strokeWidth = 3f) - - val handleColor = Color.White.copy(alpha = 0.9f) - drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f)) - drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f)) - - val playX = viewport.timeToX(playheadSec.toInt(), size.width) - drawLine(Color(0xFFFFFFFF).copy(alpha = 0.6f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f) - - drawCurve(curveLeft, selected = selectedLeft, color = leftColor, viewport = viewport) - drawCurve(curveRight, selected = selectedRight, color = rightColor, viewport = viewport) - } - } - } } diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt index 3993658..d7cfa0c 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt @@ -1,6 +1,7 @@ package com.mindmachine.mvp.session import com.mindmachine.mvp.domain.SessionPreset +import com.mindmachine.mvp.domain.VisualPattern object TimelineProgramFactory { fun fromPreset(preset: SessionPreset): TimelineEditorState { @@ -36,4 +37,45 @@ object TimelineProgramFactory { playheadSec = 0f, ) } + + fun toPreset( + id: String, + name: String, + description: String, + timeline: TimelineEditorState, + sortOrder: Int, + isUserProgram: Boolean, + ): SessionPreset { + val t0 = 0f + val flashMs = ParameterMapping.logMap01( + timeline.visualLeft.valueAt(t0), + ParameterRanges.FLASH_INTERVAL_MS_MIN, + ParameterRanges.FLASH_INTERVAL_MS_MAX, + ).toInt().coerceIn(ParameterRanges.FLASH_INTERVAL_MS_MIN.toInt(), ParameterRanges.FLASH_INTERVAL_MS_MAX.toInt()) + val carrierHz = ParameterMapping.logMap01( + timeline.audioLeft.valueAt(t0), + ParameterRanges.CARRIER_HZ_MIN, + ParameterRanges.CARRIER_HZ_MAX, + ) + val binauralHz = ParameterMapping.logMap01( + timeline.audioRight.valueAt(t0), + ParameterRanges.BINAURAL_HZ_MIN, + ParameterRanges.BINAURAL_HZ_MAX, + ) + + return SessionPreset( + id = id, + name = name, + description = description, + defaultDurationSec = timeline.durationSec, + visualPatternType = VisualPattern.FLASH, + flashIntervalMs = flashMs, + intensityPercent = 60, + carrierFrequencyHz = carrierHz, + binauralDifferenceHz = binauralHz, + cautionNote = "Stop if discomfort occurs.", + sortOrder = sortOrder, + isUserProgram = isUserProgram, + ) + } }