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 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.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.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@ -22,26 +25,28 @@ import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale 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.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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.PacerService
import net.jeena.pacer.PacerViewModel import net.jeena.pacer.PacerViewModel
import net.jeena.pacer.ui.theme.Green80
private val BG = Color(0xFF0D0D0D) private val BG = Color(0xFF0A0A0A)
private val GREEN = Green80 private val FG = Color(0xFFF0EDE8)
private val GREEN_DIM = Color(0xFF004D14) private val ACCENT = Color(0xFFE8FF47)
private val DIM = Color(0xFF333333)
private val PRESETS = listOf( private val PRESETS = listOf(
100 to "slow", 100 to "slow",
@ -53,156 +58,229 @@ private val PRESETS = listOf(
@Composable @Composable
fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) { fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
val isPlaying = viewModel.isPlaying val isPlaying = viewModel.isPlaying
val bpm = viewModel.bpm val bpm = viewModel.bpm
val volume = viewModel.volume 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) { LaunchedEffect(beepTick) {
if (beepTick > 0L) { if (beepTick > 0L) {
pulseTrigger = true launch {
delay(80) bpmFlash.snapTo(1f)
pulseTrigger = false 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) { LaunchedEffect(isPlaying) {
if (!isPlaying) pulseTrigger = false if (!isPlaying) {
progress.snapTo(0f)
rippleAlpha.snapTo(0f)
dotScale.snapTo(1f)
bpmFlash.snapTo(0f)
}
} }
val pulseScale by animateFloatAsState( val bpmColor = lerp(FG, ACCENT, bpmFlash.value)
targetValue = if (pulseTrigger) 1.25f else 1f,
animationSpec = tween(durationMillis = 80),
label = "pulse"
)
Column( Box(modifier = modifier.background(BG)) {
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)) Column(
modifier = Modifier
// Pulse ring + start/stop button .fillMaxSize()
Box(contentAlignment = Alignment.Center) { .padding(horizontal = 24.dp)
Box( .padding(top = 40.dp, bottom = 20.dp),
modifier = Modifier horizontalAlignment = Alignment.CenterHorizontally,
.size(180.dp) verticalArrangement = Arrangement.SpaceBetween
.scale(pulseScale) ) {
.border( // Title
width = 2.dp, Text(
color = GREEN.copy(alpha = if (isPlaying) 0.7f else 0.2f), text = "PACER",
shape = CircleShape 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() }, onClick = { viewModel.togglePlayback() },
modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, if (isPlaying) ACCENT else FG),
shape = CircleShape, colors = ButtonDefaults.outlinedButtonColors(
colors = ButtonDefaults.buttonColors( contentColor = if (isPlaying) BG else FG,
containerColor = if (isPlaying) GREEN else GREEN_DIM containerColor = if (isPlaying) ACCENT else Color.Transparent
) )
) { ) {
Text( Text(
text = if (isPlaying) "STOP" else "START", text = if (isPlaying) "STOP" else "START",
fontSize = 16.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = BG letterSpacing = 3.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
) )
} }
}
Spacer(Modifier.height(8.dp)) // BPM slider + presets
Column(modifier = Modifier.fillMaxWidth()) {
// BPM slider Text(
Column(modifier = Modifier.fillMaxWidth()) { text = "PACE",
Text( fontSize = 10.sp,
text = "PACE", fontFamily = FontFamily.Monospace,
fontSize = 12.sp, color = FG.copy(alpha = 0.35f),
fontFamily = FontFamily.Monospace, letterSpacing = 3.sp
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
) )
) Slider(
} value = bpm.toFloat(),
onValueChange = { viewModel.updateBpm(it.toInt()) },
// Preset buttons valueRange = 40f..200f,
Row( colors = SliderDefaults.colors(
modifier = Modifier.fillMaxWidth(), thumbColor = ACCENT,
horizontalArrangement = Arrangement.SpaceEvenly activeTrackColor = ACCENT,
) { inactiveTrackColor = DIM
PRESETS.forEach { (preset, label) -> )
OutlinedButton( )
onClick = { viewModel.updateBpm(preset) }, Row(
modifier = Modifier.size(width = 72.dp, height = 52.dp), modifier = Modifier.fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), horizontalArrangement = Arrangement.SpaceEvenly
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))
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { PRESETS.forEach { (preset, label) ->
Text( val active = bpm == preset
text = "$preset", OutlinedButton(
fontSize = 14.sp, onClick = { viewModel.updateBpm(preset) },
fontWeight = FontWeight.Bold, modifier = Modifier.size(width = 72.dp, height = 48.dp),
fontFamily = FontFamily.Monospace contentPadding = PaddingValues(0.dp),
) border = BorderStroke(1.dp, if (active) ACCENT else DIM),
Text( colors = ButtonDefaults.outlinedButtonColors(
text = label, contentColor = if (active) BG else FG.copy(alpha = 0.6f),
fontSize = 10.sp, containerColor = if (active) ACCENT else Color.Transparent
fontFamily = FontFamily.Monospace )
) ) {
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 // Progress bar — fills linearly across one beat interval
Column(modifier = Modifier.fillMaxWidth()) { Box(
Text( modifier = Modifier
text = "VOLUME ${(volume * 100).toInt()}%", .fillMaxWidth(progress.value)
fontSize = 12.sp, .height(3.dp)
fontFamily = FontFamily.Monospace, .background(ACCENT)
color = GREEN.copy(alpha = 0.6f) .align(Alignment.BottomStart)
) )
Slider(
value = volume, // Scanline overlay
onValueChange = { viewModel.updateVolume(it) }, Canvas(modifier = Modifier.fillMaxSize()) {
valueRange = 0f..1f, val stripe = 4.dp.toPx()
colors = SliderDefaults.colors( var y = 0f
thumbColor = GREEN, while (y < size.height) {
activeTrackColor = GREEN, drawRect(
inactiveTrackColor = GREEN_DIM 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> <resources>
<color name="green_primary">#FF00FF41</color> <color name="green_primary">#FF00FF41</color>
<color name="dark_background">#FF0D0D0D</color> <color name="dark_background">#FF0D0D0D</color>
<color name="bg">#FF0A0A0A</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
</resources> </resources>

View file

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