Back to Blog

Build a Pomodoro Timer & Package It as a DMG — Compose Desktop | Kotlin Desktop Lesson 20

Sandy LaneSandy Lane

Video: Build a Pomodoro Timer & Package It as a DMG — Compose Desktop | Kotlin Desktop Lesson 20 by Taught by Celeste AI - AI Coding Coach

Watch full page →

Build a Pomodoro Timer & Package It as a DMG — Compose Desktop

This tutorial demonstrates how to create a Pomodoro Timer using Jetpack Compose Desktop featuring a circular progress ring and phase-specific colors. It also covers configuring Gradle's nativeDistributions block to package the app as a native macOS DMG installer with a single command.

Code

import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import java.awt.Toolkit

enum class Phase(val durationSeconds: Int, val color: androidx.compose.ui.graphics.Color) {
  Work(25 * 60, androidx.compose.ui.graphics.Color(0xFF4CAF50)),       // Green
  ShortBreak(5 * 60, androidx.compose.ui.graphics.Color(0xFF2196F3)), // Blue
  LongBreak(15 * 60, androidx.compose.ui.graphics.Color(0xFFFFC107)) // Amber
}

@Composable
@Preview
fun PomodoroTimer() {
  var phase by remember { mutableStateOf(Phase.Work) }
  var secondsLeft by remember { mutableStateOf(phase.durationSeconds) }
  var sessionCount by remember { mutableStateOf(0) }

  // Countdown timer effect
  LaunchedEffect(phase, secondsLeft) {
    if (secondsLeft > 0) {
      delay(1000L)
      secondsLeft--
    } else {
      Toolkit.getDefaultToolkit().beep() // Alert on phase end
      // Advance phase logic
      when (phase) {
        Phase.Work -> {
          sessionCount++
          phase = if (sessionCount % 4 == 0) Phase.LongBreak else Phase.ShortBreak
          secondsLeft = phase.durationSeconds
        }
        else -> {
          phase = Phase.Work
          secondsLeft = phase.durationSeconds
        }
      }
    }
  }

  val progress = 1f - secondsLeft.toFloat() / phase.durationSeconds

  Column(
    modifier = Modifier.fillMaxSize().padding(32.dp),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
  ) {
    // Circular progress ring
    Canvas(modifier = Modifier.size(200.dp)) {
      val strokeWidth = 20f
      val radius = size.minDimension / 2 - strokeWidth / 2
      drawArc(
        color = phase.color,
        startAngle = -90f,
        sweepAngle = 360f * progress,
        useCenter = false,
        style = Stroke(strokeWidth, cap = StrokeCap.Round)
      )
    }

    Spacer(Modifier.height(24.dp))

    Text(
      text = "${secondsLeft / 60}:${(secondsLeft % 60).toString().padStart(2, '0')}",
      style = MaterialTheme.typography.displayLarge,
      color = phase.color
    )

    Spacer(Modifier.height(16.dp))

    Text(
      text = when (phase) {
        Phase.Work -> "Work"
        Phase.ShortBreak -> "Short Break"
        Phase.LongBreak -> "Long Break"
      },
      style = MaterialTheme.typography.headlineMedium
    )

    Spacer(Modifier.height(32.dp))

    // Session tracking dots
    Row {
      repeat(4) { index ->
        Canvas(
          modifier = Modifier.size(24.dp).padding(4.dp)
        ) {
          drawCircle(
            color = if (index < sessionCount % 4) phase.color else phase.color.copy(alpha = 0.3f),
            style = if (index < sessionCount % 4) Stroke(width = 0f) else Stroke(width = 2f)
          )
        }
      }
    }
  }
}

// Gradle nativeDistributions block example (build.gradle.kts):
/*
compose.desktop {
  application {
    mainClass = "MainKt"
    nativeDistributions {
      targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg)
      packageName = "PomodoroTimer"
      packageVersion = "1.0.0"
      vendor = "YourName"
      description = "A Pomodoro Timer built with Compose Desktop"
      macOS {
        bundleID = "com.yourname.pomodoro"
      }
    }
  }
}
*/

// To package the app as a DMG on macOS, run:
// ./gradlew packageDmg

Key Points

  • Use Compose Desktop's Canvas and drawArc with StrokeCap.Round to create smooth circular progress indicators.
  • Manage Pomodoro phases and countdown with LaunchedEffect and mutable state, auto-advancing between work and break sessions.
  • Track completed work sessions visually using filled and outlined circles drawn on Canvas.
  • Configure Gradle's nativeDistributions block to specify package metadata and target native installer formats like DMG for macOS.
  • Build native installers easily via Gradle commands such as ./gradlew packageDmg for macOS packaging.