From 3f38737a303d7f63cb955e3f80f98e9bb55ae8bd Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 9 Mar 2026 08:07:10 +0000 Subject: [PATCH] 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 --- app/build.gradle.kts | 1 + .../kotlin/net/jeena/pacer/MainActivity.kt | 32 ++- .../kotlin/net/jeena/pacer/PacerViewModel.kt | 46 +++++ .../kotlin/net/jeena/pacer/ui/PacerScreen.kt | 194 ++++++++++++++++++ gradle/libs.versions.toml | 1 + 5 files changed, 255 insertions(+), 19 deletions(-) create mode 100644 app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt create mode 100644 app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c68f40d..24eef48 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/kotlin/net/jeena/pacer/MainActivity.kt b/app/src/main/kotlin/net/jeena/pacer/MainActivity.kt index f9ca6ad..fab4874 100644 --- a/app/src/main/kotlin/net/jeena/pacer/MainActivity.kt +++ b/app/src/main/kotlin/net/jeena/pacer/MainActivity.kt @@ -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") - } -} diff --git a/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt b/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt new file mode 100644 index 0000000..1f56513 --- /dev/null +++ b/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt new file mode 100644 index 0000000..35934cc --- /dev/null +++ b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt @@ -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 + ) + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a0773cc..5b4e13b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }