Flash sequence UX polish + dirty tracking
This commit is contained in:
@@ -535,8 +535,9 @@ 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))
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
Reference in New Issue
Block a user