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.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue