feat(US-06): match original layout and labels

- Layout order: title → BPM → pulse ring → SLOW/FAST slider
  → presets → VOL slider → START/STOP (full-width, at bottom)
- 'steps / min' replaces 'BPM' under the number
- 'X.X steps/sec' calculated sub-label added
- Slider flanked by SLOW / FAST labels
- Volume shows 'VOL' left and '60%' right
- Default BPM 130, default volume 60% to match original
This commit is contained in:
Jeena 2026-03-09 12:09:44 +00:00
parent 12f38c19ab
commit dc1d05b1a7
3 changed files with 96 additions and 64 deletions

View file

@ -20,7 +20,7 @@ class SettingsRepository(context: Context) {
prefs.edit().putFloat(KEY_VOLUME, volume).apply() prefs.edit().putFloat(KEY_VOLUME, volume).apply()
} }
fun loadBpm(): Int = prefs.getInt(KEY_BPM, 120) fun loadBpm(): Int = prefs.getInt(KEY_BPM, 130)
fun loadVolume(): Float = prefs.getFloat(KEY_VOLUME, 1f) fun loadVolume(): Float = prefs.getFloat(KEY_VOLUME, 0.6f)
} }

View file

@ -32,10 +32,9 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -106,6 +105,7 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
} }
val bpmColor = lerp(FG, ACCENT, bpmFlash.value) val bpmColor = lerp(FG, ACCENT, bpmFlash.value)
val stepsPerSec = "%.1f steps/sec".format(bpm / 60.0)
Box(modifier = modifier.background(BG)) { Box(modifier = modifier.background(BG)) {
@ -127,6 +127,32 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
letterSpacing = 5.sp letterSpacing = 5.sp
) )
// BPM number + labels
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$bpm",
fontSize = 80.sp,
fontWeight = FontWeight.Bold,
fontFamily = JetBrainsMono,
color = bpmColor,
letterSpacing = (-2).sp
)
Text(
text = "steps / min",
fontSize = 11.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.35f),
letterSpacing = 2.sp
)
Text(
text = stepsPerSec,
fontSize = 11.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.25f),
letterSpacing = 1.sp
)
}
// Pulse ring + ripple + center dot // Pulse ring + ripple + center dot
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(80.dp)) { Box(contentAlignment = Alignment.Center, modifier = Modifier.size(80.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
@ -149,54 +175,27 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
) )
} }
// BPM number // BPM slider with SLOW / FAST labels + presets
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$bpm",
fontSize = 80.sp,
fontWeight = FontWeight.Bold,
fontFamily = JetBrainsMono,
color = bpmColor,
letterSpacing = (-2).sp
)
Text(
text = "BPM",
fontSize = 11.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.35f),
letterSpacing = 4.sp
)
}
// Start/Stop button
OutlinedButton(
onClick = { viewModel.togglePlayback() },
shape = RectangleShape,
border = BorderStroke(2.dp, if (isPlaying) ACCENT else FG),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (isPlaying) BG else FG,
containerColor = if (isPlaying) ACCENT else Color.Transparent
)
) {
Text(
text = if (isPlaying) "STOP" else "START",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fontFamily = JetBrainsMono,
letterSpacing = 3.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// BPM slider + presets
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Text( Row(
text = "PACE", modifier = Modifier.fillMaxWidth(),
fontSize = 10.sp, horizontalArrangement = Arrangement.SpaceBetween
fontFamily = JetBrainsMono, ) {
color = FG.copy(alpha = 0.35f), Text(
letterSpacing = 3.sp text = "SLOW",
) fontSize = 10.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.35f),
letterSpacing = 2.sp
)
Text(
text = "FAST",
fontSize = 10.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.35f),
letterSpacing = 2.sp
)
}
Slider( Slider(
value = bpm.toFloat(), value = bpm.toFloat(),
onValueChange = { viewModel.updateBpm(it.toInt()) }, onValueChange = { viewModel.updateBpm(it.toInt()) },
@ -245,13 +244,25 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
// Volume slider // Volume slider
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Text( Row(
text = "VOLUME ${(volume * 100).toInt()}%", modifier = Modifier.fillMaxWidth(),
fontSize = 10.sp, horizontalArrangement = Arrangement.SpaceBetween
fontFamily = JetBrainsMono, ) {
color = FG.copy(alpha = 0.35f), Text(
letterSpacing = 3.sp text = "VOL",
) fontSize = 10.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.35f),
letterSpacing = 2.sp
)
Text(
text = "${(volume * 100).toInt()}%",
fontSize = 10.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.35f),
letterSpacing = 2.sp
)
}
Slider( Slider(
value = volume, value = volume,
onValueChange = { viewModel.updateVolume(it) }, onValueChange = { viewModel.updateVolume(it) },
@ -263,6 +274,27 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
) )
) )
} }
// Start/Stop button
OutlinedButton(
onClick = { viewModel.togglePlayback() },
modifier = Modifier.fillMaxWidth(),
shape = RectangleShape,
border = BorderStroke(2.dp, if (isPlaying) ACCENT else FG),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (isPlaying) BG else FG,
containerColor = if (isPlaying) ACCENT else Color.Transparent
)
) {
Text(
text = if (isPlaying) "STOP" else "START",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fontFamily = JetBrainsMono,
letterSpacing = 3.sp,
modifier = Modifier.padding(vertical = 6.dp)
)
}
} }
// Progress bar — fills linearly across one beat interval // Progress bar — fills linearly across one beat interval

View file

@ -26,8 +26,8 @@ class SettingsRepositoryTest {
prefs = mock() prefs = mock()
whenever(prefs.edit()).thenReturn(editor) whenever(prefs.edit()).thenReturn(editor)
whenever(prefs.getInt(eq(SettingsRepository.KEY_BPM), any())).thenReturn(120) whenever(prefs.getInt(eq(SettingsRepository.KEY_BPM), any())).thenReturn(130)
whenever(prefs.getFloat(eq(SettingsRepository.KEY_VOLUME), any())).thenReturn(1f) whenever(prefs.getFloat(eq(SettingsRepository.KEY_VOLUME), any())).thenReturn(0.6f)
context = mock() context = mock()
whenever(context.getSharedPreferences(any(), any())).thenReturn(prefs) whenever(context.getSharedPreferences(any(), any())).thenReturn(prefs)
@ -62,18 +62,18 @@ class SettingsRepositoryTest {
} }
@Test @Test
fun `loadBpm returns default 120 when nothing saved`() { fun `loadBpm returns default 130 when nothing saved`() {
whenever(prefs.getInt(eq(SettingsRepository.KEY_BPM), any())).thenAnswer { whenever(prefs.getInt(eq(SettingsRepository.KEY_BPM), any())).thenAnswer {
it.getArgument(1) as Int it.getArgument(1) as Int
} }
assertEquals(120, repo.loadBpm()) assertEquals(130, repo.loadBpm())
} }
@Test @Test
fun `loadVolume returns default 1f when nothing saved`() { fun `loadVolume returns default 0_6f when nothing saved`() {
whenever(prefs.getFloat(eq(SettingsRepository.KEY_VOLUME), any())).thenAnswer { whenever(prefs.getFloat(eq(SettingsRepository.KEY_VOLUME), any())).thenAnswer {
it.getArgument(1) as Float it.getArgument(1) as Float
} }
assertEquals(1f, repo.loadVolume()) assertEquals(0.6f, repo.loadVolume())
} }
} }