What You Will Build

A functional Pomodoro timer with a circular progress arc that counts down from 25 minutes (work) or 5 minutes (break). It includes Start/Pause and Reset buttons, and automatically switches between work and break modes when the timer reaches zero. The circular arc shrinks as time passes, providing a visual countdown.

Why This Pattern Matters

Timer UIs combine state management, coroutine-based countdowns, and Canvas drawing. The Pomodoro pattern teaches you LaunchedEffect with a changing key, delay-based tick loops, and derived arc angles from time values.

The Pomodoro Timer

@Composable
fun PomodoroTimerScreen() {
    var totalSeconds by remember { mutableIntStateOf(25 * 60) }
    var remaining by remember { mutableIntStateOf(totalSeconds) }
    var isRunning by remember { mutableStateOf(false) }
    var isWork by remember { mutableStateOf(true) }

    LaunchedEffect(isRunning) {
        while (isRunning && remaining > 0) {
            delay(1000)
            remaining--
        }
        if (remaining <= 0 && isRunning) {
            isRunning = false
            isWork = !isWork
            totalSeconds = if (isWork) 25 * 60 else 5 * 60
            remaining = totalSeconds
        }
    }

    val color = if (isWork) Color(0xFFFF3B30) else Color(0xFF34C759)

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            if (isWork) "WORK" else "BREAK",
            fontSize = 16.sp, fontWeight = FontWeight.Bold,
            color = color, letterSpacing = 4.sp
        )
        Spacer(Modifier.height(24.dp))

        Box(modifier = Modifier.size(250.dp),
            contentAlignment = Alignment.Center) {
            Canvas(Modifier.fillMaxSize()) {
                drawArc(color.copy(0.15f), 0f, 360f, false,
                    style = Stroke(12f, cap = StrokeCap.Round))
                val sweep = remaining.toFloat() / totalSeconds * 360f
                drawArc(color, -90f, sweep, false,
                    style = Stroke(12f, cap = StrokeCap.Round))
            }
            val m = remaining / 60
            val s = remaining % 60
            Text(
                String.format("%02d:%02d", m, s),
                fontSize = 52.sp, fontWeight = FontWeight.Bold,
                fontFamily = FontFamily.Monospace
            )
        }

        Spacer(Modifier.height(32.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            Button(
                onClick = { isRunning = !isRunning },
                colors = ButtonDefaults.buttonColors(
                    containerColor = color)
            ) {
                Text(if (isRunning) "Pause" else "Start",
                    fontWeight = FontWeight.Bold)
            }
            OutlinedButton(onClick = {
                isRunning = false
                remaining = totalSeconds
            }) { Text("Reset") }
        }
    }
}

Tips and Pitfalls

  • LaunchedEffect(isRunning) restarts the coroutine each time isRunning changes, which handles both start and pause cleanly.
  • Use FontFamily.Monospace for the timer display so digits do not shift as numbers change width.
  • The sweep angle is calculated as remaining / totalSeconds * 360f, creating a shrinking arc as time passes.
  • Auto-switch logic: When remaining hits zero while running, toggle isWork and reset the timer to the new duration.

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.