feat(US-06): redesign UI to match original web version

- Black (#0a0a0a) background, off-white (#f0ede8) text,
  lime-yellow (#e8ff47) accent, monospace font throughout
- BPM number flashes to accent on each beat
- Pulse ring with expanding ripple and scaling center dot
- Linear progress bar sweeps across the bottom each interval
- Subtle scanline overlay for retro CRT effect
- START/STOP button inverts to accent when active
This commit is contained in:
Jeena 2026-03-09 11:42:05 +00:00
parent b2fedcfd4a
commit 8c9cdefe0c
3 changed files with 213 additions and 134 deletions

View file

@ -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",
@ -55,154 +60,227 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
val isPlaying = viewModel.isPlaying
val bpm = viewModel.bpm
val volume = viewModel.volume
// Driven by the actual audio callback — guaranteed in sync with the beep
val beepTick = PacerService.beepTick
var pulseTrigger by remember { mutableStateOf(false) }
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) }
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
}
val pulseScale by animateFloatAsState(
targetValue = if (pulseTrigger) 1.25f else 1f,
animationSpec = tween(durationMillis = 80),
label = "pulse"
)
LaunchedEffect(isPlaying) {
if (!isPlaying) {
progress.snapTo(0f)
rippleAlpha.snapTo(0f)
dotScale.snapTo(1f)
bpmFlash.snapTo(0f)
}
}
val bpmColor = lerp(FG, ACCENT, bpmFlash.value)
Box(modifier = modifier.background(BG)) {
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 32.dp),
.padding(horizontal = 24.dp)
.padding(top = 40.dp, bottom = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
// BPM display
// Title
Text(
text = "PACER",
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = FG.copy(alpha = 0.4f),
letterSpacing = 5.sp
)
// BPM number
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$bpm",
fontSize = 96.sp,
fontSize = 80.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = GREEN
color = bpmColor,
letterSpacing = (-2).sp
)
Text(
text = "BPM",
fontSize = 18.sp,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = GREEN.copy(alpha = 0.6f)
color = FG.copy(alpha = 0.35f),
letterSpacing = 4.sp
)
}
Spacer(Modifier.height(8.dp))
// Pulse ring + start/stop button
Box(contentAlignment = Alignment.Center) {
// 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(180.dp)
.scale(pulseScale)
.border(
width = 2.dp,
color = GREEN.copy(alpha = if (isPlaying) 0.7f else 0.2f),
shape = CircleShape
.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)
)
Button(
}
// 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
// BPM slider + presets
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "PACE",
fontSize = 12.sp,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = GREEN.copy(alpha = 0.6f)
color = FG.copy(alpha = 0.35f),
letterSpacing = 3.sp
)
Slider(
value = bpm.toFloat(),
onValueChange = { viewModel.updateBpm(it.toInt()) },
valueRange = 40f..200f,
colors = SliderDefaults.colors(
thumbColor = GREEN,
activeTrackColor = GREEN,
inactiveTrackColor = GREEN_DIM
thumbColor = ACCENT,
activeTrackColor = ACCENT,
inactiveTrackColor = DIM
)
)
}
// Preset buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
PRESETS.forEach { (preset, label) ->
val active = bpm == preset
OutlinedButton(
onClick = { viewModel.updateBpm(preset) },
modifier = Modifier.size(width = 72.dp, height = 52.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
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 (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))
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 = 14.sp,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Text(
text = label,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
letterSpacing = 1.sp
)
}
}
}
}
}
// Volume slider
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "VOLUME ${(volume * 100).toInt()}%",
fontSize = 12.sp,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = GREEN.copy(alpha = 0.6f)
color = FG.copy(alpha = 0.35f),
letterSpacing = 3.sp
)
Slider(
value = volume,
onValueChange = { viewModel.updateVolume(it) },
valueRange = 0f..1f,
colors = SliderDefaults.colors(
thumbColor = GREEN,
activeTrackColor = GREEN,
inactiveTrackColor = GREEN_DIM
thumbColor = ACCENT,
activeTrackColor = ACCENT,
inactiveTrackColor = 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
}
}
}
}

View file

@ -2,6 +2,7 @@
<resources>
<color name="green_primary">#FF00FF41</color>
<color name="dark_background">#FF0D0D0D</color>
<color name="bg">#FF0A0A0A</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Pacer" parent="android:Theme.DeviceDefault.NoActionBar">
<item name="android:statusBarColor">@color/dark_background</item>
<item name="android:windowBackground">@color/dark_background</item>
<item name="android:statusBarColor">@color/bg</item>
<item name="android:windowBackground">@color/bg</item>
</style>
</resources>