diff --git a/BACKLOG.md b/BACKLOG.md index 6c5e2b7..2e5e3bf 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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. - 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 - 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. diff --git a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt index d8d341e..224e0cf 100644 --- a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt +++ b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt @@ -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) + ) + } +}