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.