Flash sequence UX polish + dirty tracking
This commit is contained in:
@@ -535,8 +535,9 @@ fun SetupScreen(
|
||||
) {
|
||||
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)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -30,6 +34,8 @@ fun FlashSequenceEditor(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rows = sequence.ifEmpty { defaultFlashSequence() }
|
||||
val splitColWidth = 56.dp
|
||||
val deleteColWidth = 40.dp
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -45,24 +51,40 @@ fun FlashSequenceEditor(
|
||||
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 ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
Box(modifier = Modifier.width(splitColWidth), contentAlignment = Alignment.CenterStart) {
|
||||
Checkbox(
|
||||
checked = row.splitScreen,
|
||||
onCheckedChange = { checked ->
|
||||
onSequenceChanged(rows.toMutableList().also { it[index] = row.copy(splitScreen = checked) })
|
||||
}
|
||||
)
|
||||
Text("Split screen", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
|
||||
ColorCycleButton(
|
||||
label = if (row.splitScreen) "Left" else "Color",
|
||||
color = row.leftColor,
|
||||
onClick = {
|
||||
onSequenceChanged(rows.toMutableList().also {
|
||||
@@ -74,7 +96,6 @@ fun FlashSequenceEditor(
|
||||
|
||||
if (row.splitScreen) {
|
||||
ColorCycleButton(
|
||||
label = "Right",
|
||||
color = row.rightColor,
|
||||
onClick = {
|
||||
onSequenceChanged(rows.toMutableList().also {
|
||||
@@ -90,7 +111,8 @@ fun FlashSequenceEditor(
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSequenceChanged(rows.filterIndexed { i, _ -> i != index }.ifEmpty { defaultFlashSequence() })
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(deleteColWidth),
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Remove row", tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
@@ -109,19 +131,26 @@ fun FlashSequenceEditor(
|
||||
|
||||
@Composable
|
||||
private fun ColorCycleButton(
|
||||
label: String,
|
||||
color: FlashPaletteColor,
|
||||
onClick: () -> Unit,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.padding(end = 8.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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ data class UiState(
|
||||
data class TimelineProgramSignature(
|
||||
val durationSec: Int,
|
||||
val curveGranularitySec: Int,
|
||||
val flashSequence: List<FlashSequenceRow>,
|
||||
val visualLeft: List<TimelinePoint>,
|
||||
val visualRight: List<TimelinePoint>,
|
||||
val audioLeft: List<TimelinePoint>,
|
||||
@@ -66,6 +67,7 @@ data class TimelineProgramSignature(
|
||||
private fun timelineProgramSignature(timeline: TimelineEditorState): TimelineProgramSignature = TimelineProgramSignature(
|
||||
durationSec = timeline.durationSec,
|
||||
curveGranularitySec = timeline.curveGranularitySec,
|
||||
flashSequence = timeline.flashSequence,
|
||||
visualLeft = timeline.visualLeft.points,
|
||||
visualRight = timeline.visualRight.points,
|
||||
audioLeft = timeline.audioLeft.points,
|
||||
|
||||
@@ -6,9 +6,9 @@ import org.junit.Test
|
||||
|
||||
class DurationSliderRangeTest {
|
||||
@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(8 * 60 * 60, TimelineEditorState.default(9 * 60 * 60).durationSec)
|
||||
assertEquals(60 * 60, TimelineEditorState.default(9 * 60 * 60).durationSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -27,7 +27,7 @@ class SessionValidatorTest {
|
||||
|
||||
@Test
|
||||
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("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)))
|
||||
|
||||
Reference in New Issue
Block a user