Auto commit Tue Mar 24 06:01:01 PM CDT 2026

This commit is contained in:
Tretzi
2026-03-24 18:01:01 -05:00
parent 2a548edc9a
commit 1d4a599dd6
4 changed files with 158 additions and 25 deletions

View File

@@ -18,23 +18,35 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@@ -87,11 +99,13 @@ import com.mindmachine.mvp.session.SplitFlashFrame
import com.mindmachine.mvp.session.SplitFlashSequencer import com.mindmachine.mvp.session.SplitFlashSequencer
import com.mindmachine.mvp.session.flashIntervalMsToSeconds import com.mindmachine.mvp.session.flashIntervalMsToSeconds
import com.mindmachine.mvp.session.flashIntervalSecondsToMs import com.mindmachine.mvp.session.flashIntervalSecondsToMs
import com.mindmachine.mvp.session.formatDuration
import com.mindmachine.mvp.session.sessionProgressFraction import com.mindmachine.mvp.session.sessionProgressFraction
import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault
import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
import java.util.Locale
private val MindMachineColors = darkColorScheme( private val MindMachineColors = darkColorScheme(
primary = Color(0xFF8AB4FF), primary = Color(0xFF8AB4FF),
@@ -177,17 +191,30 @@ fun App(vm: MainViewModel = viewModel()) {
) )
} }
composable("home") { composable("home") {
Column(Modifier.fillMaxSize()) { var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) }
var confirmDeleteProgramName by remember { mutableStateOf("") }
Column(
Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
TopAppBar(title = { Text("MindMachine") }, actions = { TopAppBar(title = { Text("MindMachine") }, actions = {
TextButton(onClick = { IconButton(
vm.createNewProgramDraft() onClick = {
nav.navigate("setup") vm.createNewProgramDraft()
}) { Text("Add") } nav.navigate("setup")
TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") } }
) {
Icon(Icons.Filled.Add, contentDescription = "Add")
}
IconButton(onClick = { nav.navigate("settings") }) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
}) })
LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item { item {
TextButton(onClick = { nav.navigate("holder") }) { Text("Holder Guidance") } TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") }
} }
items(ui.presets) { p -> items(ui.presets) { p ->
Card( Card(
@@ -208,14 +235,66 @@ fun App(vm: MainViewModel = viewModel()) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(p.name, style = MaterialTheme.typography.titleMedium) Text(p.name, style = MaterialTheme.typography.titleMedium)
Text(p.description) Text(p.description)
Text("${p.defaultDurationSec / 60} min${p.visualPatternType} • binaural ${p.binauralDifferenceHz}") Text("${formatDuration(p.defaultDurationSec)}${p.visualPatternType} • binaural ${formatMaxTwoDecimals(p.binauralDifferenceHz)}")
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(30.dp)
)
Box(
modifier = Modifier
.size(24.dp)
.clickable {
confirmDeleteProgramId = p.id
confirmDeleteProgramName = p.name
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Delete ${p.name}",
tint = Color(0xFFFF5252),
modifier = Modifier.fillMaxSize()
)
}
} }
TextButton(onClick = { vm.deleteProgram(p.id) }) { Text("Delete") }
} }
} }
} }
} }
} }
if (confirmDeleteProgramId != null) {
AlertDialog(
onDismissRequest = {
confirmDeleteProgramId = null
confirmDeleteProgramName = ""
},
title = { Text("Delete program?") },
text = { Text("Delete \"$confirmDeleteProgramName\"?") },
confirmButton = {
TextButton(onClick = {
confirmDeleteProgramId?.let(vm::deleteProgram)
confirmDeleteProgramId = null
confirmDeleteProgramName = ""
}) {
Text("Delete", color = Color(0xFFFF5252))
}
},
dismissButton = {
TextButton(onClick = {
confirmDeleteProgramId = null
confirmDeleteProgramName = ""
}) { Text("Cancel") }
}
)
}
} }
composable("setup") { composable("setup") {
SetupScreen( SetupScreen(
@@ -226,7 +305,6 @@ fun App(vm: MainViewModel = viewModel()) {
nav.navigate("active") nav.navigate("active")
} }
}, },
onHolder = { nav.navigate("holder") },
) )
} }
composable("active") { composable("active") {
@@ -262,7 +340,11 @@ fun App(vm: MainViewModel = viewModel()) {
@Composable @Composable
fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) { fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) {
Column( Column(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
@@ -274,7 +356,13 @@ fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnSco
@Composable @Composable
fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) { fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
var checked by remember { mutableStateOf(false) } var checked by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp)) { Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.padding(16.dp)
) {
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
@@ -294,15 +382,15 @@ fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
fun SetupScreen( fun SetupScreen(
vm: MainViewModel, vm: MainViewModel,
onStart: () -> Unit, onStart: () -> Unit,
onHolder: () -> Unit,
) { ) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val containerModifier = Modifier val containerModifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.statusBars)
.windowInsetsPadding(WindowInsets.navigationBars)
.then(if (setupScreenUsesScrollableContainer()) Modifier.verticalScroll(scrollState) else Modifier) .then(if (setupScreenUsesScrollableContainer()) Modifier.verticalScroll(scrollState) else Modifier)
.safeDrawingPadding()
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) } var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) }
@@ -342,7 +430,6 @@ fun SetupScreen(
onCurveGranularityChanged = vm::setCurveGranularitySec, onCurveGranularityChanged = vm::setCurveGranularitySec,
) )
TextButton(onClick = onHolder) { Text("Holder Guidance") }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) { OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
@@ -360,6 +447,9 @@ fun SetupScreen(
internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String = internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String =
"Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s" "Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s"
internal fun formatMaxTwoDecimals(value: Float): String =
String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')
internal fun setupScreenHasUnsavedChanges( internal fun setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges: Boolean, hasUnsavedTimelineChanges: Boolean,
editedProgramName: String, editedProgramName: String,
@@ -436,6 +526,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.then(if (immersiveFullscreen) Modifier else Modifier.safeDrawingPadding())
.pointerInput(immersiveFullscreen) { .pointerInput(immersiveFullscreen) {
detectTapGestures { detectTapGestures {
brightnessOverlayVisible = true brightnessOverlayVisible = true
@@ -636,8 +727,14 @@ private fun rememberSplitFlashFrame(
@Composable @Composable
fun SettingsScreen(vm: MainViewModel) { fun SettingsScreen(vm: MainViewModel) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
Column( Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
@@ -671,7 +768,11 @@ fun SettingsScreen(vm: MainViewModel) {
@Composable @Composable
fun HolderScreen() { fun HolderScreen() {
Column( Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp), Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)

View File

@@ -1,13 +1,18 @@
package com.mindmachine.mvp.session package com.mindmachine.mvp.session
import java.util.Locale
fun formatDuration(totalSec: Int): String { fun formatDuration(totalSec: Int): String {
val sec = totalSec.coerceAtLeast(0) val sec = totalSec.coerceAtLeast(0)
val h = sec / 3600 val totalMinutes = sec / 60f
val m = (sec % 3600) / 60
val s = sec % 60 return if (totalMinutes <= 60f) {
return when { "${totalMinutes.toInt()} min"
h > 0 -> String.format("%dh %02dm", h, m) } else {
m > 0 -> String.format("%dm %02ds", m, s) val hours = totalMinutes / 60f
else -> String.format("%ds", s) "${formatMaxTwoDecimals(hours)} h"
} }
} }
private fun formatMaxTwoDecimals(value: Float): String =
String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')

View File

@@ -17,6 +17,13 @@ class SetupScreenUiLogicTest {
assertTrue(setupScreenUsesScrollableContainer()) assertTrue(setupScreenUsesScrollableContainer())
} }
@Test
fun program_selection_numbers_use_at_most_two_decimals() {
assertEquals("12", formatMaxTwoDecimals(12f))
assertEquals("4.5", formatMaxTwoDecimals(4.5f))
assertEquals("4.57", formatMaxTwoDecimals(4.567f))
}
@Test @Test
fun setup_screen_unsaved_changes_true_when_timeline_dirty() { fun setup_screen_unsaved_changes_true_when_timeline_dirty() {
assertTrue( assertTrue(

View File

@@ -0,0 +1,20 @@
package com.mindmachine.mvp.session
import org.junit.Assert.assertEquals
import org.junit.Test
class DurationFormatTest {
@Test
fun `duration up to 60 minutes is shown in whole minutes only`() {
assertEquals("1 min", formatDuration(60))
assertEquals("45 min", formatDuration(45 * 60 + 59))
assertEquals("60 min", formatDuration(60 * 60))
}
@Test
fun `duration over 60 minutes is shown in hours with at most two decimals`() {
assertEquals("1.02 h", formatDuration(61 * 60))
assertEquals("1.5 h", formatDuration(90 * 60))
assertEquals("2 h", formatDuration(120 * 60))
}
}