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:
parent
22fc569874
commit
d79fcebc41
2 changed files with 306 additions and 186 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package net.jeena.pacer.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
Box(modifier = modifier.background(BG)) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 64.dp, bottom = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Title
|
||||
Text(
|
||||
text = "PACER",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = JetBrainsMono,
|
||||
color = ACCENT,
|
||||
letterSpacing = 5.sp
|
||||
if (isLandscape) {
|
||||
LandscapeLayout(
|
||||
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() }
|
||||
)
|
||||
} else {
|
||||
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
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue