diff --git a/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt b/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt index 5591c6e..983b636 100644 --- a/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt +++ b/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt @@ -1,18 +1,27 @@ package net.jeena.pacer import android.app.Application +import android.os.SystemClock import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt class PacerViewModel(application: Application) : AndroidViewModel(application) { private val ctx = application.applicationContext private val settings = SettingsRepository(ctx) + private val tapTimes = mutableListOf() + private var tapResetJob: Job? = null + var bpm by mutableIntStateOf(settings.loadBpm()) private set @@ -43,4 +52,21 @@ class PacerViewModel(application: Application) : AndroidViewModel(application) { settings.saveVolume(newVolume) if (isPlaying) ctx.startService(PacerService.updateVolumeIntent(ctx, newVolume)) } + + fun onTap() { + val now = SystemClock.elapsedRealtime() + tapTimes.removeAll { now - it > 3000 } + tapTimes.add(now) + + if (tapTimes.size >= 2) { + val avgInterval = tapTimes.zipWithNext { a, b -> b - a }.average() + updateBpm((60000.0 / avgInterval).roundToInt().coerceIn(40, 200)) + } + + tapResetJob?.cancel() + tapResetJob = viewModelScope.launch { + delay(2000) + tapTimes.clear() + } + } } 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 224e0cf..a1c3413 100644 --- a/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt +++ b/app/src/main/kotlin/net/jeena/pacer/ui/PacerScreen.kt @@ -10,6 +10,8 @@ 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.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -125,7 +127,8 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) { dotScale = dotScale.value, onBpmChange = { viewModel.updateBpm(it) }, onVolChange = { viewModel.updateVolume(it) }, - onToggle = { viewModel.togglePlayback() } + onToggle = { viewModel.togglePlayback() }, + onTap = { viewModel.onTap() } ) } else { PortraitLayout( @@ -139,7 +142,8 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) { dotScale = dotScale.value, onBpmChange = { viewModel.updateBpm(it) }, onVolChange = { viewModel.updateVolume(it) }, - onToggle = { viewModel.togglePlayback() } + onToggle = { viewModel.togglePlayback() }, + onTap = { viewModel.onTap() } ) } @@ -181,6 +185,7 @@ private fun PortraitLayout( onBpmChange: (Int) -> Unit, onVolChange: (Float) -> Unit, onToggle: () -> Unit, + onTap: () -> Unit, ) { Column( modifier = Modifier @@ -195,7 +200,7 @@ private fun PortraitLayout( PulseRing(rippleScale, rippleAlpha, dotScale, size = 80.dp) BpmControls(bpm, onBpmChange) VolumeControl(volume, onVolChange) - StartStopButton(isPlaying, onToggle, Modifier.fillMaxWidth()) + BottomButtons(isPlaying, onToggle, onTap) } } @@ -212,6 +217,7 @@ private fun LandscapeLayout( onBpmChange: (Int) -> Unit, onVolChange: (Float) -> Unit, onToggle: () -> Unit, + onTap: () -> Unit, ) { Row( modifier = Modifier @@ -242,7 +248,7 @@ private fun LandscapeLayout( ) { BpmControls(bpm, onBpmChange) VolumeControl(volume, onVolChange) - StartStopButton(isPlaying, onToggle, Modifier.fillMaxWidth()) + BottomButtons(isPlaying, onToggle, onTap) } } } @@ -416,24 +422,45 @@ private fun VolumeControl(volume: Float, onVolChange: (Float) -> Unit) { } @Composable -private fun StartStopButton(isPlaying: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) { - OutlinedButton( - onClick = onToggle, - modifier = modifier, - shape = RectangleShape, - 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 = 18.sp, - fontWeight = FontWeight.Bold, - fontFamily = JetBrainsMono, - letterSpacing = 3.sp, - modifier = Modifier.padding(vertical = 6.dp) - ) +private fun BottomButtons(isPlaying: Boolean, onToggle: () -> Unit, onTap: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onTap, + modifier = Modifier.weight(1f), + shape = RectangleShape, + border = BorderStroke(2.dp, DIM), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = FG, + containerColor = Color.Transparent + ) + ) { + Text( + text = "TAP", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + fontFamily = JetBrainsMono, + letterSpacing = 3.sp, + modifier = Modifier.padding(vertical = 6.dp) + ) + } + OutlinedButton( + onClick = onToggle, + modifier = Modifier.weight(2f), + shape = RectangleShape, + 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 = 18.sp, + fontWeight = FontWeight.Bold, + fontFamily = JetBrainsMono, + letterSpacing = 3.sp, + modifier = Modifier.padding(vertical = 6.dp) + ) + } } }