Build a Pomodoro Timer & Package It as a DMG — Compose Desktop | Kotlin Desktop Lesson 20
Video: Build a Pomodoro Timer & Package It as a DMG — Compose Desktop | Kotlin Desktop Lesson 20 by Taught by Celeste AI - AI Coding Coach
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.nativeDistributionsto 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:SStext 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.iconsetdirectory or viaiconutil. - 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:
- Apple Developer account ($99/year).
- Code-signing certificate from Xcode.
- Sign the app:
codesign --deep --sign "Developer ID Application: Your Name" YourApp.app. - Notarise via
xcrun notarytool submit— Apple scans for malware and stamps the bundle. - 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/.debinto 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.