Have you tried the latest Android app release from Toggl Track? Toggl Track for Android 4.0.0 includes a Pomodoro mode. Users can track their pomodoros or use the Pomodoro Technique while tracking time.
If you’ve tried our Pomodoro mode, have you tried it in full-screen mode? If you’re curious about the soothing waves that accompany the timer, think they’re cool and are wondering how they were made, you’ve come to the right place. If you like Android, Jetpack Compose, canvases or the creative process behind the development of snazzy features, you might also find this article worth your time.
The Pomodoro full-screen can be accessed while running a Pomodoro session in the Pomodoro tab by tapping the full-screen icon at the bottom right of the screen. Here’s a quick video tutorial showing how to access the full screen:
Toggl Labs, where the idea was born
Here at Toggl Track, we have a seasonal company-wide event to try out ideas related to time tracking and productivity. We brainstorm ideas, pitch them to the whole company, recruit team members and work on them during a fixed period. At the end of the event, we present the ideas and our results. It’s one of the ways that new features are added. We call this process Toggl Labs.
The new Pomodoro feature is an amalgamation of ideas that were born during our Toggl Labs. The Pomodoro full-screen mode was originally supposed to be a self-contained “focus mode” which included wavy patterns and a running time entry.
Getting the curves right
We tried a few different ideas, including using an animated SVG bezier curve. Those worked well for the web app but didn’t for Android, so we went with a raw canvas and a manually created animated set of Bezier curves. Here’s a gist of how the code for that looked:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import com.example.gists.ui.theme.GistsTheme
import kotlin.math.*
import kotlin.random.Random
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
GistsTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
WavyBackground()
}
}
}
}
}
// these waves coordinates were actually created manually based on the %s of the screen
private val wave1 = listOf(
Offset(0.25f, -0.14f),
Offset(0.33f, 0f),
Offset(0.52f, 0.08f),
Offset(0.7f, 0.2f),
Offset(0.9f, 0.3f),
Offset(0.94f, 0.50f),
Offset(1.15f, 0.62f),
)
private val wave2 = listOf(
Offset(-0.1f, 0f),
Offset(0f, 0.1f),
Offset(0.2f, 0.085f),
Offset(0.3f, 0.2f),
Offset(0.6f, 0.3f),
Offset(0.9f, 0.57f),
Offset(1.1f, 0.65f),
)
private val wave3 = listOf(
Offset(0.25f, 1.1f),
Offset(0.30f, 1f),
Offset(0.38f, 0.95f),
Offset(0.60f, 0.9f),
Offset(0.80f, 0.8f),
Offset(1.2f, 0.82f),
)
private fun xChange() = Random.nextDouble(0.1, 0.15).toFloat()
private fun yChange() = Random.nextDouble(0.01, 0.07).toFloat()
private fun List<Offset>.buildFactors() = mapIndexed { i, _ ->
if (i == 0 || i == size - 1) Offset(0f, 0f)
else Offset(xChange(), yChange())
}
// we add some random changes to the waves
private val wave1changeFactors = wave1.buildFactors()
private val wave2changeFactors = wave2.buildFactors()
private val wave3changeFactors = wave3.buildFactors()
private val times = listOf(
1250 to 3000,
2000 to 4000,
2500 to 4250
)
@Composable
fun WavyBackground() {
val infiniteTransition = rememberInfiniteTransition()
val x1 by infiniteTransition.animateFloat(
initialValue = -0.3f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = times[0].first, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
val y1 by infiniteTransition.animateFloat(
initialValue = -0.5f,
targetValue = 0.5f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = times[0].second, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
val x2 by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = -0.3f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = times[1].first, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
val y2 by infiniteTransition.animateFloat(
initialValue = 0.5f,
targetValue = -0.5f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = times[1].second, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
val x3 by infiniteTransition.animateFloat(
initialValue = -0.3f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = times[2].first, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
val y3 by infiniteTransition.animateFloat(
initialValue = -0.5f,
targetValue = 0.5f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = times[2].second, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
Canvas(modifier = Modifier.fillMaxSize()) {
drawTopWaves(wave2, wave2changeFactors, x2, y2, Color(0xE9, 0xA0, 0xE0, 0xFF))
drawTopWaves(wave1, wave1changeFactors, x1, y1, Color(0x41, 0x2A, 0x4C, 0xFF))
drawBottomWaves(wave3, wave3changeFactors, x3, y3, Color(0xFD, 0xE5, 0xDC, 0xB2))
}
}
private fun DrawScope.drawTopWaves(wave: List<Offset>, factors: List<Offset>, xAnim: Float, yAnim: Float, color: Color) {
Path().apply {
val first = wave.first()
moveTo(first.x * size.width, first.y * size.height)
for (i in 1 until wave.size) {
smoothBezierTo(xAnim, yAnim, size, wave[i], i, wave, factors)
}
lineTo(size.width, -size.height)
close()
drawPath(
path = this,
color = color,
style = Fill
)
}
}
private fun DrawScope.drawBottomWaves(wave: List<Offset>, factors: List<Offset>, xAnim: Float, yAnim: Float, color: Color) {
Path().apply {
val first = wave.first()
moveTo(first.x * size.width, first.y * size.height)
for (i in 1 until wave.size) {
smoothBezierTo(xAnim, yAnim, size, wave[i], i, wave, factors)
}
lineTo(size.width, size.height)
close()
drawPath(
path = this,
color = color,
style = Fill
)
}
}
private fun Offset.multiplyBy(x: Float, y: Float) = copy(this.x * x, this.y * y)
// this method tries to select a control point for the bezier curve that would keep the overall shape of the wave smooth
private fun Path.smoothBezierTo(xAnim: Float, yAnim: Float, size: Size, point: Offset, index: Int, points: List<Offset>, factors: List<Offset>) {
val adjustedPoint = point + factors[index].multiplyBy(xAnim, yAnim)
val prevAdjustedPoint = points[index - 1] + factors[index - 1].multiplyBy(xAnim, yAnim)
val nextAdjustedPoint = points.getOrNull(index + 1)?.plus(factors.getOrElse(index + 1) { Offset.Zero }.multiplyBy(xAnim, yAnim))
val prevPrevAdjustedPoint = points.getOrNull(index - 2)?.plus(factors.getOrElse(index - 2) { Offset.Zero }.multiplyBy(xAnim, yAnim))
val startControlPoint = controlPoint(prevAdjustedPoint, prevPrevAdjustedPoint, adjustedPoint)
val endControlPoint = controlPoint(adjustedPoint, prevAdjustedPoint, nextAdjustedPoint, true)
cubicTo(
startControlPoint.x * size.width,
startControlPoint.y * size.height,
endControlPoint.x * size.width,
endControlPoint.y * size.height,
adjustedPoint.x * size.width,
adjustedPoint.y * size.height
)
}
private fun Offset.lengthTo(other: Offset) =
sqrt((other.x - this.x).pow(2f) + (other.y - this.y).pow(2f))
private fun Offset.angleTo(other: Offset) =
atan2((other.y - this.y), (other.x - this.x))
private const val smoothing = 0.2f
private fun controlPoint(current: Offset, previous: Offset?, next: Offset?, reverse: Boolean = false): Offset {
val prev = previous ?: current
val nxt = next ?: current
val angle = prev.angleTo(nxt) + if (reverse) PI.toFloat() else 0f
val length = prev.lengthTo(nxt) * smoothing
return Offset(
x = current.x + cos(angle) * length,
y = current.y + sin(angle) * length
)
}
The current waves (at least on version 4.0.0) were created using a similar approach, but instead of the clunky Bezier curves, we went with the simpler and easier-to-reason-about sin-waves. Here’s a gist of how the code for the current implementation looks.
import android.content.res.Configuration
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.tooling.preview.Preview
import kotlin.math.sin
import kotlin.random.Random
@Composable
fun WavyBackground(
topWaveColor: Color,
middleWaveColor: Color,
bottomWaveColor: Color
) {
Box(modifier = Modifier.fillMaxSize()) {
Wave(
waveData = WaveData(
color = topWaveColor,
closeOnTop = false,
frequency = 420f,
durationInMillis = 250_000L,
amplitude = 50f
),
yFunctionOverXPercentage = {
0.6f
}
)
Wave(
waveData = WaveData(
color = middleWaveColor,
closeOnTop = false,
frequency = 760f,
durationInMillis = 200_000L,
amplitude = 60f
),
yFunctionOverXPercentage = {
0.65f
}
)
Wave(
waveData = WaveData(
color = bottomWaveColor,
closeOnTop = false,
frequency = 960f,
durationInMillis = 300_000L
),
yFunctionOverXPercentage = { 0.7f }
)
}
}
@Composable
fun Wave(
modifier: Modifier = Modifier,
waveData: WaveData,
startXPercentage: Float = 0f,
yFunctionOverXPercentage: (Float) -> Float
) {
val randomSeed = remember {
Random.nextDouble(0.0, waveData.frequency.toDouble()).toFloat()
}
val infTransition = rememberInfiniteTransition()
val frequency by infTransition.animateFloat(
initialValue = 0f,
targetValue = waveData.frequency,
animationSpec = infiniteRepeatable(
animation = tween((waveData.durationInMillis).toInt(), easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
Canvas(modifier = modifier.fillMaxSize()) {
val width = size.width.toInt()
var minHeight = 0f
val path = Path().apply {
if (waveData.closeOnTop) {
moveTo(0f, 0.0f)
} else {
moveTo(0f, size.height)
}
for (i in (startXPercentage * width).toInt()..width) {
val y =
size.height * yFunctionOverXPercentage(i / size.width) +
(sin(i * waveData.length + (frequency + randomSeed) % waveData.frequency) * waveData.amplitude)
minHeight = minOf(minHeight, y)
lineTo(i.toFloat(), y)
}
if (waveData.closeOnTop) {
lineTo(size.width, minHeight)
} else {
lineTo(size.width, size.height)
}
close()
}
clipRect {
clipPath(path) {
drawPath(path, waveData.color, style = Fill)
}
}
}
}
data class WaveData(
val color: Color,
val amplitude: Float = 67f,
val length: Float = 0.005f,
val frequency: Float = 360f,
val durationInMillis: Long = 300_000L,
val closeOnTop: Boolean = true
)
@Preview(
name = "Light Mode",
widthDp = 320,
heightDp = 480,
uiMode = Configuration.UI_MODE_NIGHT_NO
)
@Preview(
name = "Dark Mode",
widthDp = 320,
heightDp = 480,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PomodoroFullScreenPreview() {
Surface(color = MaterialTheme.colors.background) {
Box(modifier = Modifier.fillMaxSize()) {
WavyBackground(
Color(0x5, 0x18, 0x63, 0xFF),
Color(0x15, 0x6F, 0xBE, 0xFF),
Color(0xA0, 0xC1, 0xE9, 0xFF),
)
}
}
}
The behavior of the waves can be changed by modifying the yFunctionOverXPercentage
lambda and the WaveData
properties passed to the Wave
composable. The yFunctionOverXPercentage
will be called with the current x
percentage to compose the calculation of the sin wave pattern seen in the waves.
That can be used to produce various patterns as seen in the gist and of course in the Toggl Track Android app. For example, the first screenshot is simply using a fixed value for the lambda and the second is using sqrt(it)
to produce its pattern.
Thanks for your time and I hope you learned something or at least enjoyed reading about our process.
References and useful links
- A link to download the Toggl Track Android app.
- A link to the open-source Komposable Architecture, which was developed and is used in the Toggl Track apps. You might also find it interesting to read about it in this blog post.
- This Medium post from François Romain was used as an inspiration for the bezier curves.
- This YouTube tutorial from Chris Courses was used as a reference when implementing the sin waves.