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 6600bde..3f894eb 100644
--- a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt
+++ b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt
@@ -1,20 +1,23 @@
package net.jeena.pacer.ui
-import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
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.PaddingValues
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
@@ -22,26 +25,28 @@ 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.geometry.Offset
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.lerp
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 kotlinx.coroutines.launch
+import net.jeena.pacer.BpmCalculator
import net.jeena.pacer.PacerService
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 BG = Color(0xFF0A0A0A)
+private val FG = Color(0xFFF0EDE8)
+private val ACCENT = Color(0xFFE8FF47)
+private val DIM = Color(0xFF333333)
private val PRESETS = listOf(
100 to "slow",
@@ -53,156 +58,229 @@ private val PRESETS = listOf(
@Composable
fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
val isPlaying = viewModel.isPlaying
- val bpm = viewModel.bpm
- val volume = viewModel.volume
+ val bpm = viewModel.bpm
+ val volume = viewModel.volume
+ val beepTick = PacerService.beepTick
+
+ val bpmFlash = remember { Animatable(0f) }
+ val rippleScale = remember { Animatable(1f) }
+ val rippleAlpha = remember { Animatable(0f) }
+ val dotScale = remember { Animatable(1f) }
+ val progress = remember { Animatable(0f) }
- // Driven by the actual audio callback — guaranteed in sync with the beep
- val beepTick = PacerService.beepTick
- var pulseTrigger by remember { mutableStateOf(false) }
LaunchedEffect(beepTick) {
if (beepTick > 0L) {
- pulseTrigger = true
- delay(80)
- pulseTrigger = false
+ launch {
+ bpmFlash.snapTo(1f)
+ bpmFlash.animateTo(0f, tween(120))
+ }
+ launch {
+ rippleScale.snapTo(1f)
+ rippleAlpha.snapTo(0.8f)
+ launch { rippleScale.animateTo(2.5f, tween(400)) }
+ rippleAlpha.animateTo(0f, tween(400))
+ }
+ launch {
+ dotScale.snapTo(1.5f)
+ dotScale.animateTo(1f, tween(120))
+ }
+ launch {
+ progress.snapTo(0f)
+ progress.animateTo(
+ 1f,
+ tween(BpmCalculator.intervalMs(bpm).toInt(), easing = LinearEasing)
+ )
+ }
}
}
+
LaunchedEffect(isPlaying) {
- if (!isPlaying) pulseTrigger = false
+ if (!isPlaying) {
+ progress.snapTo(0f)
+ rippleAlpha.snapTo(0f)
+ dotScale.snapTo(1f)
+ bpmFlash.snapTo(0f)
+ }
}
- val pulseScale by animateFloatAsState(
- targetValue = if (pulseTrigger) 1.25f else 1f,
- animationSpec = tween(durationMillis = 80),
- label = "pulse"
- )
+ val bpmColor = lerp(FG, ACCENT, bpmFlash.value)
- 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)
- )
+ Box(modifier = modifier.background(BG)) {
- 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
- )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp)
+ .padding(top = 40.dp, bottom = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ // Title
+ Text(
+ text = "PACER",
+ fontSize = 13.sp,
+ fontFamily = FontFamily.Monospace,
+ color = FG.copy(alpha = 0.4f),
+ letterSpacing = 5.sp
)
- Button(
+
+ // BPM number
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "$bpm",
+ fontSize = 80.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ color = bpmColor,
+ letterSpacing = (-2).sp
+ )
+ Text(
+ text = "BPM",
+ fontSize = 11.sp,
+ fontFamily = FontFamily.Monospace,
+ color = FG.copy(alpha = 0.35f),
+ letterSpacing = 4.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, if (isPlaying) ACCENT else DIM, CircleShape)
+ )
+ Box(
+ modifier = Modifier
+ .size(12.dp)
+ .scale(dotScale.value)
+ .background(if (isPlaying) ACCENT else DIM, CircleShape)
+ )
+ }
+
+ // Start/Stop button
+ OutlinedButton(
onClick = { viewModel.togglePlayback() },
- modifier = Modifier.size(120.dp),
- shape = CircleShape,
- colors = ButtonDefaults.buttonColors(
- containerColor = if (isPlaying) GREEN else GREEN_DIM
+ 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 = 16.sp,
+ fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
- color = BG
+ letterSpacing = 3.sp,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
- }
- 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
+ // BPM slider + presets
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "PACE",
+ fontSize = 10.sp,
+ fontFamily = FontFamily.Monospace,
+ color = FG.copy(alpha = 0.35f),
+ letterSpacing = 3.sp
)
- )
- }
-
- // Preset buttons
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly
- ) {
- PRESETS.forEach { (preset, label) ->
- OutlinedButton(
- onClick = { viewModel.updateBpm(preset) },
- modifier = Modifier.size(width = 72.dp, height = 52.dp),
- contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
- 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))
+ 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
) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(
- text = "$preset",
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- fontFamily = FontFamily.Monospace
- )
- Text(
- text = label,
- fontSize = 10.sp,
- fontFamily = FontFamily.Monospace
- )
+ PRESETS.forEach { (preset, label) ->
+ val active = bpm == preset
+ OutlinedButton(
+ onClick = { viewModel.updateBpm(preset) },
+ modifier = Modifier.size(width = 72.dp, height = 48.dp),
+ 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 = FontFamily.Monospace
+ )
+ Text(
+ text = label,
+ fontSize = 9.sp,
+ fontFamily = FontFamily.Monospace,
+ letterSpacing = 1.sp
+ )
+ }
+ }
}
}
}
+
+ // Volume slider
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "VOLUME ${(volume * 100).toInt()}%",
+ fontSize = 10.sp,
+ fontFamily = FontFamily.Monospace,
+ color = FG.copy(alpha = 0.35f),
+ letterSpacing = 3.sp
+ )
+ Slider(
+ value = volume,
+ onValueChange = { viewModel.updateVolume(it) },
+ valueRange = 0f..1f,
+ colors = SliderDefaults.colors(
+ thumbColor = ACCENT,
+ activeTrackColor = ACCENT,
+ inactiveTrackColor = DIM
+ )
+ )
+ }
}
- // 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
+ // Progress bar — fills linearly across one beat interval
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(progress.value)
+ .height(3.dp)
+ .background(ACCENT)
+ .align(Alignment.BottomStart)
+ )
+
+ // Scanline overlay
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val stripe = 4.dp.toPx()
+ var y = 0f
+ while (y < size.height) {
+ drawRect(
+ color = Color.Black.copy(alpha = 0.07f),
+ topLeft = Offset(0f, y),
+ size = Size(size.width, stripe / 2)
)
- )
+ y += stripe
+ }
}
}
}
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 1ed1427..30c36e0 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -2,6 +2,7 @@
#FF00FF41
#FF0D0D0D
+ #FF0A0A0A
#FF000000
#FFFFFFFF
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index cf9048b..becdb10 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,7 +1,7 @@