diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index f49b0d6..33baa09 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -18,23 +18,35 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding 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.verticalScroll import androidx.compose.foundation.lazy.LazyColumn 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.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.LinearProgressIndicator 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.flashIntervalMsToSeconds import com.mindmachine.mvp.session.flashIntervalSecondsToMs +import com.mindmachine.mvp.session.formatDuration import com.mindmachine.mvp.session.sessionProgressFraction import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen import kotlinx.coroutines.delay import kotlin.math.roundToInt +import java.util.Locale private val MindMachineColors = darkColorScheme( primary = Color(0xFF8AB4FF), @@ -177,17 +191,30 @@ fun App(vm: MainViewModel = viewModel()) { ) } composable("home") { - Column(Modifier.fillMaxSize()) { + var confirmDeleteProgramId by remember { mutableStateOf(null) } + var confirmDeleteProgramName by remember { mutableStateOf("") } + + Column( + Modifier + .fillMaxSize() + .safeDrawingPadding() + ) { TopAppBar(title = { Text("MindMachine") }, actions = { - TextButton(onClick = { - vm.createNewProgramDraft() - nav.navigate("setup") - }) { Text("Add") } - TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") } + IconButton( + onClick = { + vm.createNewProgramDraft() + nav.navigate("setup") + } + ) { + 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)) { item { - TextButton(onClick = { nav.navigate("holder") }) { Text("Holder Guidance") } + TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") } } items(ui.presets) { p -> Card( @@ -208,14 +235,66 @@ fun App(vm: MainViewModel = viewModel()) { Column(Modifier.weight(1f)) { Text(p.name, style = MaterialTheme.typography.titleMedium) 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") { SetupScreen( @@ -226,7 +305,6 @@ fun App(vm: MainViewModel = viewModel()) { nav.navigate("active") } }, - onHolder = { nav.navigate("holder") }, ) } composable("active") { @@ -262,7 +340,11 @@ fun App(vm: MainViewModel = viewModel()) { @Composable fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) { 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), ) { Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) @@ -274,7 +356,13 @@ fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnSco @Composable fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) { 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) Spacer(Modifier.height(12.dp)) Text( @@ -294,15 +382,15 @@ fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) { fun SetupScreen( vm: MainViewModel, onStart: () -> Unit, - onHolder: () -> Unit, ) { val ui by vm.ui.collectAsStateWithLifecycle() val scrollState = rememberScrollState() val containerModifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding(WindowInsets.statusBars) + .windowInsetsPadding(WindowInsets.navigationBars) .then(if (setupScreenUsesScrollableContainer()) Modifier.verticalScroll(scrollState) else Modifier) - .safeDrawingPadding() .padding(horizontal = 16.dp, vertical = 12.dp) var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) } @@ -342,7 +430,6 @@ fun SetupScreen( onCurveGranularityChanged = vm::setCurveGranularitySec, ) - TextButton(onClick = onHolder) { Text("Holder Guidance") } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { if (hasUnsavedChanges) { OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) { @@ -360,6 +447,9 @@ fun SetupScreen( internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String = "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( hasUnsavedTimelineChanges: Boolean, editedProgramName: String, @@ -436,6 +526,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup: modifier = Modifier .fillMaxSize() .background(Color.Black) + .then(if (immersiveFullscreen) Modifier else Modifier.safeDrawingPadding()) .pointerInput(immersiveFullscreen) { detectTapGestures { brightnessOverlayVisible = true @@ -636,8 +727,14 @@ private fun rememberSplitFlashFrame( @Composable fun SettingsScreen(vm: MainViewModel) { val ui by vm.ui.collectAsStateWithLifecycle() + val scrollState = rememberScrollState() 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) ) { Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) @@ -671,7 +768,11 @@ fun SettingsScreen(vm: MainViewModel) { @Composable fun HolderScreen() { 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) ) { Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) diff --git a/app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt b/app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt index 7004e8e..7f42240 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt @@ -1,13 +1,18 @@ package com.mindmachine.mvp.session +import java.util.Locale + fun formatDuration(totalSec: Int): String { val sec = totalSec.coerceAtLeast(0) - val h = sec / 3600 - val m = (sec % 3600) / 60 - val s = sec % 60 - return when { - h > 0 -> String.format("%dh %02dm", h, m) - m > 0 -> String.format("%dm %02ds", m, s) - else -> String.format("%ds", s) + val totalMinutes = sec / 60f + + return if (totalMinutes <= 60f) { + "${totalMinutes.toInt()} min" + } else { + val hours = totalMinutes / 60f + "${formatMaxTwoDecimals(hours)} h" } } + +private fun formatMaxTwoDecimals(value: Float): String = + String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.') diff --git a/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt b/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt index cfbda64..b28eb4b 100644 --- a/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/SetupScreenUiLogicTest.kt @@ -17,6 +17,13 @@ class SetupScreenUiLogicTest { 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 fun setup_screen_unsaved_changes_true_when_timeline_dirty() { assertTrue( diff --git a/app/src/test/java/com/mindmachine/mvp/session/DurationFormatTest.kt b/app/src/test/java/com/mindmachine/mvp/session/DurationFormatTest.kt new file mode 100644 index 0000000..a047142 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/session/DurationFormatTest.kt @@ -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)) + } +}