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 @@