Flash sequence UX polish + dirty tracking

This commit is contained in:
Tretzi
2026-04-15 10:26:46 -05:00
parent 50272cd6a6
commit 180fd0124e
5 changed files with 45 additions and 13 deletions

View File

@@ -535,9 +535,10 @@ fun SetupScreen(
) { ) {
Text("Save") Text("Save")
} }
} } else {
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") } 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) if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }

View File

@@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
@@ -16,11 +18,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
@@ -30,6 +34,8 @@ fun FlashSequenceEditor(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val rows = sequence.ifEmpty { defaultFlashSequence() } val rows = sequence.ifEmpty { defaultFlashSequence() }
val splitColWidth = 56.dp
val deleteColWidth = 40.dp
Column( Column(
modifier = modifier modifier = modifier
@@ -45,24 +51,40 @@ fun FlashSequenceEditor(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
) )
// Table header row.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.width(splitColWidth), contentAlignment = Alignment.CenterStart) {
Text("Split", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
Text("Left", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
Text("Right", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
}
Box(modifier = Modifier.width(deleteColWidth))
}
rows.forEachIndexed { index, row -> rows.forEachIndexed { index, row ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { Box(modifier = Modifier.width(splitColWidth), contentAlignment = Alignment.CenterStart) {
Checkbox( Checkbox(
checked = row.splitScreen, checked = row.splitScreen,
onCheckedChange = { checked -> onCheckedChange = { checked ->
onSequenceChanged(rows.toMutableList().also { it[index] = row.copy(splitScreen = checked) }) onSequenceChanged(rows.toMutableList().also { it[index] = row.copy(splitScreen = checked) })
} }
) )
Text("Split screen", color = MaterialTheme.colorScheme.onSurface)
} }
ColorCycleButton( ColorCycleButton(
label = if (row.splitScreen) "Left" else "Color",
color = row.leftColor, color = row.leftColor,
onClick = { onClick = {
onSequenceChanged(rows.toMutableList().also { onSequenceChanged(rows.toMutableList().also {
@@ -74,7 +96,6 @@ fun FlashSequenceEditor(
if (row.splitScreen) { if (row.splitScreen) {
ColorCycleButton( ColorCycleButton(
label = "Right",
color = row.rightColor, color = row.rightColor,
onClick = { onClick = {
onSequenceChanged(rows.toMutableList().also { onSequenceChanged(rows.toMutableList().also {
@@ -90,7 +111,8 @@ fun FlashSequenceEditor(
IconButton( IconButton(
onClick = { onClick = {
onSequenceChanged(rows.filterIndexed { i, _ -> i != index }.ifEmpty { defaultFlashSequence() }) onSequenceChanged(rows.filterIndexed { i, _ -> i != index }.ifEmpty { defaultFlashSequence() })
} },
modifier = Modifier.width(deleteColWidth),
) { ) {
Icon(Icons.Default.Delete, contentDescription = "Remove row", tint = MaterialTheme.colorScheme.onSurface) Icon(Icons.Default.Delete, contentDescription = "Remove row", tint = MaterialTheme.colorScheme.onSurface)
} }
@@ -109,19 +131,26 @@ fun FlashSequenceEditor(
@Composable @Composable
private fun ColorCycleButton( private fun ColorCycleButton(
label: String,
color: FlashPaletteColor, color: FlashPaletteColor,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OutlinedButton(onClick = onClick, modifier = modifier) { OutlinedButton(
onClick = onClick,
modifier = modifier.heightIn(min = 40.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
) {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(end = 8.dp) .padding(end = 8.dp)
.background(color.previewColor(), RoundedCornerShape(6.dp)) .background(color.previewColor(), RoundedCornerShape(6.dp))
.padding(horizontal = 10.dp, vertical = 10.dp) .padding(horizontal = 10.dp, vertical = 8.dp)
)
Text(
text = color.displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
Text("$label: ${color.displayName}")
} }
} }

View File

@@ -57,6 +57,7 @@ data class UiState(
data class TimelineProgramSignature( data class TimelineProgramSignature(
val durationSec: Int, val durationSec: Int,
val curveGranularitySec: Int, val curveGranularitySec: Int,
val flashSequence: List<FlashSequenceRow>,
val visualLeft: List<TimelinePoint>, val visualLeft: List<TimelinePoint>,
val visualRight: List<TimelinePoint>, val visualRight: List<TimelinePoint>,
val audioLeft: List<TimelinePoint>, val audioLeft: List<TimelinePoint>,
@@ -66,6 +67,7 @@ data class TimelineProgramSignature(
private fun timelineProgramSignature(timeline: TimelineEditorState): TimelineProgramSignature = TimelineProgramSignature( private fun timelineProgramSignature(timeline: TimelineEditorState): TimelineProgramSignature = TimelineProgramSignature(
durationSec = timeline.durationSec, durationSec = timeline.durationSec,
curveGranularitySec = timeline.curveGranularitySec, curveGranularitySec = timeline.curveGranularitySec,
flashSequence = timeline.flashSequence,
visualLeft = timeline.visualLeft.points, visualLeft = timeline.visualLeft.points,
visualRight = timeline.visualRight.points, visualRight = timeline.visualRight.points,
audioLeft = timeline.audioLeft.points, audioLeft = timeline.audioLeft.points,

View File

@@ -6,9 +6,9 @@ import org.junit.Test
class DurationSliderRangeTest { class DurationSliderRangeTest {
@Test @Test
fun timeline_duration_clamps_to_one_minute_and_eight_hours() { fun timeline_duration_clamps_to_one_minute_and_sixty_minutes() {
assertEquals(60, TimelineEditorState.default(30).durationSec) assertEquals(60, TimelineEditorState.default(30).durationSec)
assertEquals(8 * 60 * 60, TimelineEditorState.default(9 * 60 * 60).durationSec) assertEquals(60 * 60, TimelineEditorState.default(9 * 60 * 60).durationSec)
} }
@Test @Test

View File

@@ -27,7 +27,7 @@ class SessionValidatorTest {
@Test @Test
fun out_of_range_values_are_blocked() { fun out_of_range_values_are_blocked() {
assertEquals("Duration must be 1 minute to 8 hours.", SessionValidator.validate(valid.copy(durationSec = 59))) assertEquals("Duration must be 1 minute to 60 minutes.", SessionValidator.validate(valid.copy(durationSec = 59)))
assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49))) assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49)))
assertEquals("Carrier frequency must be between 200 and 1200 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 0f))) assertEquals("Carrier frequency must be between 200 and 1200 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 0f)))
assertEquals("Carrier frequency must be between 200 and 1200 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 20001f))) assertEquals("Carrier frequency must be between 200 and 1200 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 20001f)))