feat(US-06, US-07): implement retro UI with pulse animation

Dark-themed Compose screen with:
- Large monospace BPM counter and pace slider (40-200 BPM)
- Six preset buttons (80/100/120/130/140/160 BPM)
- Volume slider with percentage readout
- Circular start/stop button with green pulse ring that
  animates on each beat via a coroutine-driven LaunchedEffect
- PacerViewModel owns AudioEngine lifecycle so audio survives
  configuration changes
This commit is contained in:
Jeena 2026-03-09 08:07:10 +00:00
parent def69f1a46
commit 3f38737a30
5 changed files with 255 additions and 19 deletions

View file

@ -52,6 +52,7 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.lifecycle.viewmodel.compose)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View file

@ -4,36 +4,30 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.Color
import net.jeena.pacer.ui.PacerScreen
import net.jeena.pacer.ui.theme.PacerTheme
class MainActivity : ComponentActivity() {
private val viewModel: PacerViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PacerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Text(
text = "Pacer",
modifier = Modifier.padding(innerPadding)
)
}
PacerScreen(
viewModel = viewModel,
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0D0D0D))
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
PacerTheme {
Text("Pacer")
}
}

View file

@ -0,0 +1,46 @@
package net.jeena.pacer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class PacerViewModel : ViewModel() {
private val audioEngine = AudioEngine()
var bpm by mutableIntStateOf(120)
private set
var volume by mutableFloatStateOf(1f)
private set
var isPlaying by mutableStateOf(false)
private set
fun togglePlayback() {
if (isPlaying) {
audioEngine.stop()
isPlaying = false
} else {
audioEngine.start(bpm, volume)
isPlaying = true
}
}
fun updateBpm(newBpm: Int) {
bpm = newBpm
audioEngine.setBpm(newBpm)
}
fun updateVolume(newVolume: Float) {
volume = newVolume
audioEngine.setVolume(newVolume)
}
override fun onCleared() {
audioEngine.stop()
}
}

View file

@ -0,0 +1,194 @@
package net.jeena.pacer.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import net.jeena.pacer.BpmCalculator
import net.jeena.pacer.PacerViewModel
import net.jeena.pacer.ui.theme.Green80
private val BG = Color(0xFF0D0D0D)
private val GREEN = Green80
private val GREEN_DIM = Color(0xFF004D14)
private val PRESETS = listOf(80, 100, 120, 130, 140, 160)
@Composable
fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
val isPlaying = viewModel.isPlaying
val bpm = viewModel.bpm
val volume = viewModel.volume
// Pulse trigger: fires once per beat when playing
var pulseTrigger by remember { mutableStateOf(false) }
LaunchedEffect(isPlaying, bpm) {
if (isPlaying) {
while (true) {
pulseTrigger = true
delay(80)
pulseTrigger = false
delay(BpmCalculator.intervalMs(bpm) - 80)
}
} else {
pulseTrigger = false
}
}
val pulseScale by animateFloatAsState(
targetValue = if (pulseTrigger) 1.25f else 1f,
animationSpec = tween(durationMillis = 80),
label = "pulse"
)
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
// BPM display
Text(
text = "$bpm",
fontSize = 96.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = GREEN
)
Text(
text = "BPM",
fontSize = 18.sp,
fontFamily = FontFamily.Monospace,
color = GREEN.copy(alpha = 0.6f)
)
Spacer(Modifier.height(8.dp))
// Pulse ring + start/stop button
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(180.dp)
.scale(pulseScale)
.border(
width = 2.dp,
color = GREEN.copy(alpha = if (isPlaying) 0.7f else 0.2f),
shape = CircleShape
)
)
Button(
onClick = { viewModel.togglePlayback() },
modifier = Modifier.size(120.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = if (isPlaying) GREEN else GREEN_DIM
)
) {
Text(
text = if (isPlaying) "STOP" else "START",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = BG
)
}
}
Spacer(Modifier.height(8.dp))
// BPM slider
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "PACE",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = GREEN.copy(alpha = 0.6f)
)
Slider(
value = bpm.toFloat(),
onValueChange = { viewModel.updateBpm(it.toInt()) },
valueRange = 40f..200f,
colors = SliderDefaults.colors(
thumbColor = GREEN,
activeTrackColor = GREEN,
inactiveTrackColor = GREEN_DIM
)
)
}
// Preset buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
PRESETS.forEach { preset ->
OutlinedButton(
onClick = { viewModel.updateBpm(preset) },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (bpm == preset) BG else GREEN,
containerColor = if (bpm == preset) GREEN else Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(1.dp, GREEN.copy(alpha = 0.5f))
) {
Text(
text = "$preset",
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
// Volume slider
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "VOLUME ${(volume * 100).toInt()}%",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = GREEN.copy(alpha = 0.6f)
)
Slider(
value = volume,
onValueChange = { viewModel.updateVolume(it) },
valueRange = 0f..1f,
colors = SliderDefaults.colors(
thumbColor = GREEN,
activeTrackColor = GREEN,
inactiveTrackColor = GREEN_DIM
)
)
}
}
}

View file

@ -21,6 +21,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }