ui: Add landscape two-column layout (US-18)

Detect orientation via LocalConfiguration and switch to a Row-based
two-column layout in landscape mode. Left column shows the title, BPM
display and pulse ring; right column shows the BPM slider, presets,
volume slider and the START/STOP button. All controls remain visible
without scrolling. Portrait layout is unchanged.

Extracted shared UI into private composables (Title, BpmDisplay,
PulseRing, BpmControls, VolumeControl, StartStopButton) to avoid
duplication between the two layout paths.
This commit is contained in:
Jeena 2026-03-09 12:31:47 +00:00
parent 22fc569874
commit d79fcebc41
2 changed files with 306 additions and 186 deletions

View file

@ -68,6 +68,10 @@ US-15: As a user, I want an app icon and splash screen so that it feels native.
US-16: As a developer, I want a signed APK for installation so that the app can be deployed. US-16: As a developer, I want a signed APK for installation so that the app can be deployed.
- Acceptance: Release build generated; offline functionality verified. - Acceptance: Release build generated; offline functionality verified.
### Theme 8: Landscape Support
US-18: As a user, I want the app to work in landscape orientation so that I can use it however I hold my phone.
- Acceptance: In landscape, all controls are visible and usable without scrolling; layout switches to a two-column arrangement (pulse ring + BPM display on the left, sliders/buttons on the right); no content is clipped or overlapping.
## Additional Notes ## Additional Notes
- TDD: Focus on US-03, US-04, US-13 first—write failing tests, then implement. - TDD: Focus on US-03, US-04, US-13 first—write failing tests, then implement.
- Priorities: Complete setup (US-01-02), then audio (US-03-05), UI (US-06-07), service (US-08-10), rest. - Priorities: Complete setup (US-01-02), then audio (US-03-05), UI (US-06-07), service (US-08-10), rest.

View file

@ -1,5 +1,6 @@
package net.jeena.pacer.ui package net.jeena.pacer.ui
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -35,6 +37,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape 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.platform.LocalConfiguration
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
@ -104,197 +107,40 @@ 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) val stepsPerSec = "%.1f steps/sec".format(bpm / 60.0)
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
Box(modifier = modifier.background(BG)) { Box(modifier = modifier.background(BG)) {
Column( if (isLandscape) {
modifier = Modifier LandscapeLayout(
.fillMaxSize() isPlaying = isPlaying,
.padding(horizontal = 24.dp) bpm = bpm,
.padding(top = 64.dp, bottom = 48.dp), volume = volume,
horizontalAlignment = Alignment.CenterHorizontally, bpmColor = bpmColor,
verticalArrangement = Arrangement.SpaceBetween stepsPerSec = stepsPerSec,
) { rippleScale = rippleScale.value,
// Title rippleAlpha = rippleAlpha.value,
Text( dotScale = dotScale.value,
text = "PACER", onBpmChange = { viewModel.updateBpm(it) },
fontSize = 28.sp, onVolChange = { viewModel.updateVolume(it) },
fontWeight = FontWeight.Bold, onToggle = { viewModel.togglePlayback() }
fontFamily = JetBrainsMono, )
color = ACCENT, } else {
letterSpacing = 5.sp PortraitLayout(
isPlaying = isPlaying,
bpm = bpm,
volume = volume,
bpmColor = bpmColor,
stepsPerSec = stepsPerSec,
rippleScale = rippleScale.value,
rippleAlpha = rippleAlpha.value,
dotScale = dotScale.value,
onBpmChange = { viewModel.updateBpm(it) },
onVolChange = { viewModel.updateVolume(it) },
onToggle = { viewModel.togglePlayback() }
) )
// 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 = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
Text(
text = stepsPerSec.uppercase(),
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.5f),
letterSpacing = 1.sp
)
}
// Pulse ring + ripple + center dot
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(80.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = ACCENT.copy(alpha = rippleAlpha.value),
radius = (size.minDimension / 2f) * rippleScale.value,
style = Stroke(width = 2.dp.toPx())
)
}
Box(
modifier = Modifier
.size(50.dp)
.border(2.dp, ACCENT, CircleShape)
)
Box(
modifier = Modifier
.size(12.dp)
.scale(dotScale.value)
.background(ACCENT, CircleShape)
)
}
// BPM slider with SLOW / FAST labels + presets
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "SLOW",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
Text(
text = "FAST",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
}
Slider(
value = bpm.toFloat(),
onValueChange = { viewModel.updateBpm(it.toInt()) },
valueRange = 40f..200f,
colors = SliderDefaults.colors(
thumbColor = ACCENT,
activeTrackColor = ACCENT,
inactiveTrackColor = DIM
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
PRESETS.forEach { (preset, label) ->
val active = bpm == preset
OutlinedButton(
onClick = { viewModel.updateBpm(preset) },
modifier = Modifier.size(width = 72.dp, height = 48.dp),
shape = RectangleShape,
contentPadding = PaddingValues(0.dp),
border = BorderStroke(1.dp, if (active) ACCENT else DIM),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (active) BG else FG.copy(alpha = 0.6f),
containerColor = if (active) ACCENT else Color.Transparent
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$preset",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
fontFamily = JetBrainsMono
)
Text(
text = label.uppercase(),
fontSize = 10.sp,
fontFamily = JetBrainsMono,
letterSpacing = 1.sp
)
}
}
}
}
}
// Volume slider
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "VOL",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
Text(
text = "${(volume * 100).toInt()}%",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
}
Slider(
value = volume,
onValueChange = { viewModel.updateVolume(it) },
valueRange = 0f..1f,
colors = SliderDefaults.colors(
thumbColor = ACCENT,
activeTrackColor = ACCENT,
inactiveTrackColor = DIM
)
)
}
// 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
@ -321,3 +167,273 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
} }
} }
} }
@Composable
private fun PortraitLayout(
isPlaying: Boolean,
bpm: Int,
volume: Float,
bpmColor: Color,
stepsPerSec: String,
rippleScale: Float,
rippleAlpha: Float,
dotScale: Float,
onBpmChange: (Int) -> Unit,
onVolChange: (Float) -> Unit,
onToggle: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.padding(top = 64.dp, bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Title()
BpmDisplay(bpm, bpmColor, stepsPerSec)
PulseRing(rippleScale, rippleAlpha, dotScale, size = 80.dp)
BpmControls(bpm, onBpmChange)
VolumeControl(volume, onVolChange)
StartStopButton(isPlaying, onToggle, Modifier.fillMaxWidth())
}
}
@Composable
private fun LandscapeLayout(
isPlaying: Boolean,
bpm: Int,
volume: Float,
bpmColor: Color,
stepsPerSec: String,
rippleScale: Float,
rippleAlpha: Float,
dotScale: Float,
onBpmChange: (Int) -> Unit,
onVolChange: (Float) -> Unit,
onToggle: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Left column: title + BPM + pulse ring
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
Title()
BpmDisplay(bpm, bpmColor, stepsPerSec)
PulseRing(rippleScale, rippleAlpha, dotScale, size = 64.dp)
}
// Right column: sliders + presets + start/stop
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
BpmControls(bpm, onBpmChange)
VolumeControl(volume, onVolChange)
StartStopButton(isPlaying, onToggle, Modifier.fillMaxWidth())
}
}
}
@Composable
private fun Title() {
Text(
text = "PACER",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
fontFamily = JetBrainsMono,
color = ACCENT,
letterSpacing = 5.sp
)
}
@Composable
private fun BpmDisplay(bpm: Int, bpmColor: Color, stepsPerSec: String) {
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 = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
Text(
text = stepsPerSec.uppercase(),
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.5f),
letterSpacing = 1.sp
)
}
}
@Composable
private fun PulseRing(rippleScale: Float, rippleAlpha: Float, dotScale: Float, size: androidx.compose.ui.unit.Dp) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size)) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = ACCENT.copy(alpha = rippleAlpha),
radius = (this.size.minDimension / 2f) * rippleScale,
style = Stroke(width = 2.dp.toPx())
)
}
Box(
modifier = Modifier
.size(size * 0.625f)
.border(2.dp, ACCENT, CircleShape)
)
Box(
modifier = Modifier
.size(size * 0.15f)
.scale(dotScale)
.background(ACCENT, CircleShape)
)
}
}
@Composable
private fun BpmControls(bpm: Int, onBpmChange: (Int) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "SLOW",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
Text(
text = "FAST",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
}
Slider(
value = bpm.toFloat(),
onValueChange = { onBpmChange(it.toInt()) },
valueRange = 40f..200f,
colors = SliderDefaults.colors(
thumbColor = ACCENT,
activeTrackColor = ACCENT,
inactiveTrackColor = DIM
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
PRESETS.forEach { (preset, label) ->
val active = bpm == preset
OutlinedButton(
onClick = { onBpmChange(preset) },
modifier = Modifier.size(width = 72.dp, height = 48.dp),
shape = RectangleShape,
contentPadding = PaddingValues(0.dp),
border = BorderStroke(1.dp, if (active) ACCENT else DIM),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (active) BG else FG.copy(alpha = 0.6f),
containerColor = if (active) ACCENT else Color.Transparent
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$preset",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
fontFamily = JetBrainsMono
)
Text(
text = label.uppercase(),
fontSize = 10.sp,
fontFamily = JetBrainsMono,
letterSpacing = 1.sp
)
}
}
}
}
}
}
@Composable
private fun VolumeControl(volume: Float, onVolChange: (Float) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "VOL",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
Text(
text = "${(volume * 100).toInt()}%",
fontSize = 13.sp,
fontFamily = JetBrainsMono,
color = FG.copy(alpha = 0.65f),
letterSpacing = 2.sp
)
}
Slider(
value = volume,
onValueChange = { onVolChange(it) },
valueRange = 0f..1f,
colors = SliderDefaults.colors(
thumbColor = ACCENT,
activeTrackColor = ACCENT,
inactiveTrackColor = DIM
)
)
}
}
@Composable
private fun StartStopButton(isPlaying: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) {
OutlinedButton(
onClick = onToggle,
modifier = modifier,
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)
)
}
}