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:
parent
def69f1a46
commit
3f38737a30
5 changed files with 255 additions and 19 deletions
|
|
@ -52,6 +52,7 @@ dependencies {
|
||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|
|
||||||
|
|
@ -4,36 +4,30 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.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
|
import net.jeena.pacer.ui.theme.PacerTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val viewModel: PacerViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
PacerTheme {
|
PacerTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
PacerScreen(
|
||||||
Text(
|
viewModel = viewModel,
|
||||||
text = "Pacer",
|
modifier = Modifier
|
||||||
modifier = Modifier.padding(innerPadding)
|
.fillMaxSize()
|
||||||
)
|
.background(Color(0xFF0D0D0D))
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun DefaultPreview() {
|
|
||||||
PacerTheme {
|
|
||||||
Text("Pacer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
46
app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt
Normal file
46
app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
194
app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt
Normal file
194
app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue