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:
parent
b2fedcfd4a
commit
8c9cdefe0c
3 changed files with 213 additions and 134 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue