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
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue