Back to Blog

Multi-Window Chat App in Compose Desktop | Kotlin Desktop #13

Sandy LaneSandy Lane

Video: Multi-Window Chat App in Compose Desktop | Kotlin Desktop #13 by Taught by Celeste AI - AI Coding Coach

Watch full page →

Multi-Window Chat App in Compose Desktop with Kotlin

Discover how to build a multi-window chat application using Kotlin Compose Desktop by leveraging the application composable to manage multiple windows. This approach demonstrates dynamic window creation, shared state management across windows, and integration of system tray icons for enhanced desktop experience.

Code

import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import java.awt.image.BufferedImage
import java.awt.Graphics2D
import java.awt.Color
import javax.swing.SwingUtilities

// Data classes for chat
data class Contact(val id: Int, val name: String)
data class Message(val sender: String, val content: String)

// Shared chat state with mutableStateListOf for reactive updates
class ChatState {
  val contacts = listOf(
    Contact(1, "Alice"),
    Contact(2, "Bob"),
    Contact(3, "Carol")
  )
  val openChats = mutableStateListOf()
  val messages = mutableStateMapOf>().apply {
    contacts.forEach { put(it.id, mutableListOf()) }
  }
}

@Composable
fun ContactList(chatState: ChatState, onOpenChat: (Contact) -> Unit) {
  LazyColumn(modifier = Modifier.fillMaxHeight().width(200.dp).padding(8.dp)) {
    items(chatState.contacts) { contact ->
      ListItem(
        modifier = Modifier.clickable { onOpenChat(contact) },
        text = { Text(contact.name) }
      )
      Divider()
    }
  }
}

@Composable
fun ChatWindow(contact: Contact, chatState: ChatState, onClose: () -> Unit) {
  var input by remember { mutableStateOf("") }
  val messages = chatState.messages[contact.id] ?: emptyList()

  Window(
    onCloseRequest = onClose,
    title = "Chat with ${contact.name}",
    state = rememberWindowState(width = 400.dp, height = 300.dp)
  ) {
    Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
      LazyColumn(modifier = Modifier.weight(1f)) {
        items(messages) { message ->
          Text("${message.sender}: ${message.content}")
        }
      }
      Row {
        TextField(
          value = input,
          onValueChange = { input = it },
          modifier = Modifier.weight(1f),
          singleLine = true,
          placeholder = { Text("Type a message") },
          // Send message on Enter key press
          keyboardActions = KeyboardActions(onSend = {
            if (input.isNotBlank()) {
              chatState.messages[contact.id]?.add(Message("You", input))
              input = ""
            }
          })
        )
        Spacer(modifier = Modifier.width(8.dp))
        Button(onClick = {
          if (input.isNotBlank()) {
            chatState.messages[contact.id]?.add(Message("You", input))
            input = ""
          }
        }) {
          Text("Send")
        }
      }
    }
  }
}

fun createTrayIconImage(): BufferedImage {
  val size = 16
  val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
  val g: Graphics2D = image.createGraphics()
  g.color = Color(0, 120, 215)
  g.fillOval(0, 0, size, size)
  g.dispose()
  return image
}

@Composable
@Preview
fun App() {
  val chatState = remember { ChatState() }
  val trayState = rememberTrayState()
  val openChats = chatState.openChats

  application {
    // System tray icon with menu to quit
    Tray(
      state = trayState,
      icon = createTrayIconImage(),
      tooltip = "Multi-Window Chat",
      menu = {
        Item("Quit", onClick = ::exitApplication)
      }
    )

    // Main window showing contact list
    Window(
      onCloseRequest = ::exitApplication,
      title = "Contacts",
      state = rememberWindowState(width = 220.dp, height = 400.dp)
    ) {
      ContactList(chatState) { contact ->
        if (!openChats.contains(contact)) {
          openChats.add(contact)
        }
      }
    }

    // Dynamically create chat windows for each open chat
    for (contact in openChats) {
      key(contact.id) {
        ChatWindow(contact, chatState) {
          openChats.remove(contact)
        }
      }
    }
  }
}

Key Points

  • The application composable manages the app lifecycle and allows multiple Window composables to create multiple windows.
  • Dynamic windows are created by iterating over a mutable state list (mutableStateListOf) and using key() for stable identity tracking.
  • Shared state across windows is maintained with mutableStateListOf and mutableStateMapOf to keep messages and open chats reactive.
  • The system tray icon is created programmatically with Java 2D and integrated using the Tray composable for desktop convenience.
  • rememberWindowState controls window size and position, while keyboard actions and TextField handle Enter-to-send message input.