Products Toggl Track Working at Toggl

Behind the scenes with the Toggl Track Android team: How we made our groovy Pomodoro waves

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

November 22, 2021