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

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Pomodoro Timer + DMG Packaging: Ship Your App

A polished 25-minute focus timer with a Canvas-drawn ring, three phases, session dots, and compose.desktop.application.nativeDistributions to build native installers (.dmg, .msi, .deb). The bridge from "code on my machine" to "ships to users."

This is the final lesson. We bring everything together — animations, state, Canvas, coroutines — into a small focus timer, then package it as a native installer so anyone can run it without Java or Gradle.

What we are building

A 400×600 window with:

  • Phase label ("Work" / "Short Break" / "Long Break") in a phase-coloured tint.
  • Session dots — 4 circles, filled as you complete work cycles.
  • Timer ring — a circular progress ring with MM:SS text in the centre.
  • Controls — Start/Pause, Reset, Skip.

And a build.gradle.kts block that produces signed native installers.

Pomodoro state model

enum class TimerPhase { WORK, SHORT_BREAK, LONG_BREAK }

val phaseDurations = mapOf(
  TimerPhase.WORK to 25 * 60,
  TimerPhase.SHORT_BREAK to 5 * 60,
  TimerPhase.LONG_BREAK to 15 * 60,
)

fun phaseColor(phase: TimerPhase): Color = when (phase) {
  TimerPhase.WORK -> Color(0xFFE53935)        // red
  TimerPhase.SHORT_BREAK -> Color(0xFF43A047) // green
  TimerPhase.LONG_BREAK -> Color(0xFF1E88E5)  // blue
}

Three phases. 25/5/15 minutes is the canonical Pomodoro cycle. After every 4 work sessions, the next break is the long one.

Each phase has a colour — the timer ring, the label, the start button, the dots all tint by the active phase. One enum drives the whole UI's mood.

The countdown loop

LaunchedEffect(isRunning, currentPhase) {
  while (isRunning && remainingSeconds > 0) {
    delay(1000)
    remainingSeconds--
  }
  if (isRunning && remainingSeconds == 0) {
    Toolkit.getDefaultToolkit().beep()
    val nextPhase = when (currentPhase) {
      TimerPhase.WORK -> {
        completedSessions++
        if (completedSessions % 4 == 0) TimerPhase.LONG_BREAK
        else TimerPhase.SHORT_BREAK
      }
      TimerPhase.SHORT_BREAK -> TimerPhase.WORK
      TimerPhase.LONG_BREAK -> {
        completedSessions = 0
        TimerPhase.WORK
      }
    }
    currentPhase = nextPhase
    remainingSeconds = phaseDurations[nextPhase]!!
  }
}

LaunchedEffect(isRunning, currentPhase) re-runs whenever either changes. Inside:

  • Loop while running and time remains; sleep one second, decrement.
  • When the timer hits zero, beep, transition to the next phase.

The keys (isRunning, currentPhase) make the effect cancel-and-restart on phase change. Pause flips isRunning = false → the effect cancels mid-delay(1000) cleanly. No stray ticks.

Toolkit.getDefaultToolkit().beep() is a simple system beep. For better audio, use javax.sound.sampled to play a custom sound file.

TimerCircle: the progress ring

@Composable
fun TimerCircle(progress: Float, timeText: String, color: Color) {
  Box(contentAlignment = Alignment.Center, modifier = Modifier.size(220.dp)) {
    Canvas(modifier = Modifier.fillMaxSize()) {
      val strokeWidth = 12.dp.toPx()
      val arcSize = Size(size.width - strokeWidth, size.height - strokeWidth)
      val topLeft = Offset(strokeWidth / 2, strokeWidth / 2)

      drawArc(
        color = color.copy(alpha = 0.2f),
        startAngle = 0f,
        sweepAngle = 360f,
        useCenter = false,
        topLeft = topLeft,
        size = arcSize,
        style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
      )
      drawArc(
        color = color,
        startAngle = -90f,
        sweepAngle = 360f * progress,
        useCenter = false,
        topLeft = topLeft,
        size = arcSize,
        style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
      )
    }
    Text(
      text = timeText,
      fontSize = 48.sp,
      fontWeight = FontWeight.Bold,
      color = color,
    )
  }
}

Same arc pattern as Lesson 12's progress ring — a faint background ring, a coloured progress arc, the text overlaid via Box(contentAlignment = Alignment.Center).

progress = remainingSeconds / totalSeconds — starts at 1.0 (full ring), counts down to 0.0 (empty). For a "fills as you progress" version, flip to 1 - progress.

Session dots

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  val sessionsInCycle = completedSessions % 4
  for (i in 0 until 4) {
    Canvas(modifier = Modifier.size(12.dp)) {
      if (i < sessionsInCycle) {
        drawCircle(color = color)
      } else {
        drawCircle(color = color.copy(alpha = 0.3f), style = Stroke(width = 2.dp.toPx()))
      }
    }
  }
}

Four dots in a row. Filled if you've completed that session in the current 4-cycle, hollow otherwise. Tiny Canvas { drawCircle(...) } per dot — efficient and perfectly aligned.

Controls

Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
  Button(
    onClick = { isRunning = !isRunning },
    colors = ButtonDefaults.buttonColors(containerColor = color),
  ) { Text(if (isRunning) "Pause" else "Start") }

  OutlinedButton(onClick = {
    isRunning = false
    remainingSeconds = phaseDurations[currentPhase]!!
  }) { Text("Reset") }

  OutlinedButton(onClick = { /* skip to next phase */ }) { Text("Skip") }
}

Three buttons. Start/Pause toggles isRunning; Reset zeroes the time but keeps the phase; Skip advances the phase manually (useful when you're testing or honestly need to break early).

The Start button's colour matches the active phase — red while working, green during short break, blue during long break.

Native packaging: build.gradle.kts

import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
  kotlin("jvm") version "2.1.0"
  id("org.jetbrains.compose") version "1.7.3"
  id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
}

compose.desktop {
  application {
    mainClass = "MainKt"

    nativeDistributions {
      targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
      packageName = "Pomodoro Timer"
      packageVersion = "1.0.0"
      description = "A desktop Pomodoro Timer built with Compose"
      vendor = "CelesteAI"

      macOS {
        bundleID = "com.celesteai.pomodoro"
      }
      windows {
        menuGroup = "Pomodoro Timer"
      }
      linux {
        debMaintainer = "info@celesteai.com"
      }
    }
  }
}

Two pieces.

mainClass = "MainKt" — the JVM entry point. Kotlin compiles top-level fun main() in Main.kt to a class named MainKt.

nativeDistributions { ... } configures jpackage-style packaging:

  • TargetFormat.Dmg — macOS installer.
  • TargetFormat.Msi — Windows installer.
  • TargetFormat.Deb — Debian/Ubuntu package.
  • Other options: Pkg (mac), Exe (win), Rpm (rpm-based linux).

packageName, packageVersion, description, vendor show up in the OS package manager / system info.

macOS { bundleID = "..." } is required for macOS notarisation. Pick a reverse-DNS bundle ID you control.

Building the installer

./gradlew packageDmg     # macOS .dmg
./gradlew packageMsi     # Windows .msi
./gradlew packageDeb     # Linux .deb
./gradlew package        # all formats for current OS

Output lands in build/compose/binaries/main/. The .dmg is double-clickable on macOS; the user drags the app to /Applications.

JBR (JetBrains Runtime) ships inside the bundle by default — users don't need Java installed.

App icons

nativeDistributions {
  macOS {
    bundleID = "com.celesteai.pomodoro"
    iconFile.set(project.file("icon.icns"))
  }
  windows {
    iconFile.set(project.file("icon.ico"))
  }
  linux {
    iconFile.set(project.file("icon.png"))
  }
}

Each platform wants its own icon format:

  • macOS.icns, generated from a .iconset directory or via iconutil.
  • Windows.ico, multi-size icon file.
  • Linux.png (usually 256×256 or larger).

For a one-shot tool, use a generator like Image2Icon (Mac) or ImageMagick (convert icon.png icon.ico). For a real product, commission the icon family.

Signing and notarising (macOS)

A .dmg you build locally won't open on someone else's Mac without a Gatekeeper warning. To distribute publicly:

  1. Apple Developer account ($99/year).
  2. Code-signing certificate from Xcode.
  3. Sign the app: codesign --deep --sign "Developer ID Application: Your Name" YourApp.app.
  4. Notarise via xcrun notarytool submit — Apple scans for malware and stamps the bundle.
  5. Staple the notarisation: xcrun stapler staple YourApp.app.

Compose's Gradle plugin has a compose.desktop.application.nativeDistributions.macOS.signing { ... } block that automates steps 3–5. See the JetBrains docs for the exact wiring.

For Windows code-signing, you need an Authenticode certificate. For Linux, .deb packages are usually signed with GPG.

Distribution

  • GitHub Releases — drag the .dmg/.msi/.deb into a release. Free, version-tagged, easy.
  • Direct download from a website — host the file, link to it.
  • Mac App Store / Microsoft Store — more involved (sandboxing, store reviews), but better discoverability.

For a hobby app or a tool for a small team, GitHub Releases is fine. For a paid product, the stores plus a website.

What about Linux AppImage?

TargetFormat.Deb produces a Debian .deb. For distros like Fedora/CentOS, use TargetFormat.Rpm. For a self-contained Linux executable that runs anywhere, AppImage is the de-facto standard — but it's not built-in to Compose's Gradle plugin. Wrap your .deb with appimagetool post-build, or look for community plugins.

Common mistakes

Forgetting mainClass = "MainKt". Build succeeds but the installer launches nothing. Always set it.

Building a release without testing the actual installer. A signed .dmg may open differently than ./gradlew run. Build, drag to /Applications, double-click, repeat.

Hardcoding paths in your app. A bundled app's working directory isn't where you developed. Use System.getProperty("user.home") for user data, getResource(...) for bundled resources.

Distributing unsigned binaries. Even the best apps look sketchy when Gatekeeper says "this is from an unidentified developer." Sign for any release more public than your own machine.

Bundling huge JBR for tiny apps. Default JBR is ~80MB. For tiny tools, look at compose.desktop.application.nativeDistributions.modules to trim — or use jlink for a custom runtime.

What's next

That's the end of the kotlin-desktop series. From the first counter in Lesson 1 to a packaged installer, you've covered:

  • Composition fundamentals (Lessons 1–4).
  • Layout, theming, dialogs (Lessons 5–7).
  • Async, networking, persistence (Lessons 8–9).
  • Drag-and-drop, drawing, animation (Lessons 10–12).
  • Multi-window, data tables, testing (Lessons 13–15).
  • Architecture, packaging (Lessons 16–20).

Next steps: build something. A note app, a journal, a small game, a tool for your own workflow. Compose Desktop is genuinely productive for solo developers — start small, ship often.

Recap

Pomodoro state: enum TimerPhase, Map<TimerPhase, Int> for durations, phaseColor and phaseLabel keyed by phase. Countdown via LaunchedEffect(isRunning, phase) { while (isRunning) { delay(1000); remainingSeconds-- } }. Canvas drawArc for the ring. compose.desktop { application { nativeDistributions { targetFormats(Dmg, Msi, Deb) } } } for native installers. ./gradlew packageDmg builds a macOS-installable bundle.

Series complete. Build something.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.