Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display the number of unread mentions on the application tray icon #70

Merged
merged 8 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ package io.spine.examples.pingh.client
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import io.spine.core.UserId
import io.spine.examples.pingh.mentions.MentionStatus
import io.spine.examples.pingh.sessions.SessionId
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
* Manages the logic for the Pingh app.
Expand Down Expand Up @@ -107,6 +114,20 @@ public class PinghApplication private constructor(
*/
private var mentionsFlow: MentionsFlow? = null

private val _unreadMentionCount: MutableStateFlow<Int?> = MutableStateFlow(null)

/**
* The count of unread mentions for the user,
* or `null` if the user is not logged in.
*/
public val unreadMentionCount: StateFlow<Int?> = _unreadMentionCount

/**
* A job that updates the unread mention count
* whenever the state of a user's mentions changes.
*/
private var mentionsObserver: Job? = null

/**
* The application settings control flow.
*/
Expand Down Expand Up @@ -147,6 +168,8 @@ public class PinghApplication private constructor(
session.resetToGuest()
client = DesktopClient(channel)
mentionsFlow = null
_unreadMentionCount.value = null
mentionsObserver?.cancel()
settingsFlow = null
}

Expand All @@ -173,12 +196,25 @@ public class PinghApplication private constructor(
public fun startMentionsFlow(): MentionsFlow {
if (mentionsFlow == null) {
mentionsFlow = MentionsFlow(client, session, settings)
observeMentions()
} else {
mentionsFlow!!.applySettings()
}
return mentionsFlow!!
}

/**
* Observes the state of a user's mentions
* and updates the unread mention count whenever changes occur.
*/
private fun observeMentions() {
mentionsObserver = CoroutineScope(Dispatchers.Default).launch {
mentionsFlow!!.mentions.collect { mentions ->
_unreadMentionCount.value = mentions.count { it.status == MentionStatus.UNREAD }
}
}
}

/**
* Initiates the settings flow.
*
Expand All @@ -195,6 +231,7 @@ public class PinghApplication private constructor(
* Closes the client.
*/
public fun close() {
mentionsObserver?.cancel()
loginFlow?.close()
settingsFlow?.saveSettings()
client.close()
Expand Down
3 changes: 0 additions & 3 deletions desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,6 @@ compose.desktop {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageVersion = pinghVersion.extractSemanticVersion().value
macOS {
// Changes tray the icon color depending on the screen theme.
// See: https://bugs.openjdk.org/browse/JDK-8255597.
jvmArgs += "-Dapple.awt.enableTemplateImages=true"
iconFile = iconForMacOs()
infoPlist {
allowBackgroundExecution()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ package io.spine.internal.dependency

// https://github.com/spine-examples/Pingh
public object Pingh {
private const val version = "1.0.0-SNAPSHOT.30"
private const val version = "1.0.0-SNAPSHOT.31"
private const val group = "io.spine.examples.pingh"

public const val client: String = "$group:client:$version"
Expand Down
Binary file modified desktop/src/main/composeResources/drawable/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2024, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.examples.pingh.desktop

import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import java.awt.ComponentOrientation
import java.awt.GraphicsEnvironment
import java.util.Locale

/**
* A density of the screen.
*
* [LocalDensity] may change during execution,
* whereas the device screen density remains constant.
*
* Use this value when adding components outside the main application window.
*/
internal val GlobalDensity
get() = GraphicsEnvironment.getLocalGraphicsEnvironment()
.defaultScreenDevice
.defaultConfiguration
.run {
Density(
defaultTransform.scaleX.toFloat(),
fontScale = 1f
)
}

/**
* A layout direction that can be left-to-right or right-to-left.
*
* [LocalLayoutDirection] may change during execution,
* whereas the layout direction on device remains constant.
*
* Use this value when adding components outside the main application window.
*/
internal val GlobalLayoutDirection
get() = Locale.getDefault()
.run { ComponentOrientation.getOrientation(this) }
.run {
if (isLeftToRight) {
LayoutDirection.Ltr
} else {
LayoutDirection.Rtl
}
}
159 changes: 145 additions & 14 deletions desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Tray.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,38 @@
package io.spine.examples.pingh.desktop

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.toAwtImage
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Notification
import io.spine.example.pingh.desktop.generated.resources.Res
import io.spine.example.pingh.desktop.generated.resources.tray
import io.spine.examples.pingh.client.PinghApplication
import java.awt.Color
import java.awt.Font
import java.awt.Frame
import java.awt.Graphics
import java.awt.Image
import java.awt.MenuItem
import java.awt.Point
import java.awt.PopupMenu
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.TrayIcon.MessageType
import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.image.BufferedImage
import kotlin.math.min
import kotlin.math.round
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.jetbrains.compose.resources.painterResource
Expand All @@ -73,12 +85,7 @@ internal fun ApplicationScope.Tray(state: AppState) {

var tray: TrayIcon? = null

val destiny = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val icon = painterResource(Res.drawable.tray)
val awtIcon = remember(icon) {
icon.toAwtImage(destiny, layoutDirection)
}
val icon = rememberIcon(state.app)

val menu = remember {
Menu {
Expand All @@ -91,19 +98,109 @@ internal fun ApplicationScope.Tray(state: AppState) {
val onClick by rememberUpdatedState(mouseEventHandler(state, menu))

tray = remember {
TrayIcon(awtIcon, state.window.title).apply {
TrayIcon(icon, state.window.title).apply {
isImageAutoSize = true
addMouseListener(onClick)
}
}

SystemTray.getSystemTray().add(tray)
SideEffect {
if (tray.image != icon) tray.image = icon
}

val coroutineScope = rememberCoroutineScope()
state.tray
.notificationFlow
.onEach { tray.displayMessage(it) }
.launchIn(coroutineScope)

DisposableEffect(Unit) {
SystemTray.getSystemTray().add(tray)

state.tray
.notificationFlow
.onEach { tray.displayMessage(it) }
.launchIn(coroutineScope)

onDispose {
SystemTray.getSystemTray().remove(tray)
}
}
}

/**
* Returns the system tray icon of the application.
*
* If the user is logged in and there are unread mentions,
* a badge showing the number of unread mentions appears on the icon.
*/
@Composable
private fun rememberIcon(state: PinghApplication): Image {
val unread by state.unreadMentionCount.collectAsState()

// We shouldn't use `LocalDensity` here because tray's density doesn't equal it. It
MykytaPimonovTD marked this conversation as resolved.
Show resolved Hide resolved
// equals to the density of the screen on which it shows.
val density = GlobalDensity
val layoutDirection = GlobalLayoutDirection

val icon = painterResource(Res.drawable.tray)
val style = remember { TrayStyle(density) }

return remember(unread) {
val awtIcon = icon.toAwtImage(density, layoutDirection, style.iconSize.toCompose())
val buffer = BufferedImage(
style.boxSize.width,
style.boxSize.height,
BufferedImage.TYPE_INT_ARGB
)
buffer.createGraphics().apply {
drawImage(
awtIcon,
style.iconPosition.x,
style.iconPosition.y,
null
)
if (unread != null && unread!! > 0) {
drawBadge(style)
drawBadgeContent(unread.toString(), style)
}
dispose()
}
return@remember buffer
}
}

/**
* Draws red badge for number of unread mentions.
*/
private fun Graphics.drawBadge(style: TrayStyle) {
color = style.badgeColor
val arc = min(style.badgeSize.width, style.badgeSize.height)
fillRoundRect(
style.badgePosition.x,
style.badgePosition.y,
style.badgeSize.width,
style.badgeSize.height,
arc, arc
)
}

/**
* Draws the number of unread mentions.
*
* If the number exceeds 99, only the last two digits are shown,
* with an ellipsis preceding them.
*/
private fun Graphics.drawBadgeContent(text: String, style: TrayStyle) {
val content = if (text.length <= 2) text else ".." + text.takeLast(2)
font = Font(style.fontName, Font.PLAIN, style.fontSize)
color = style.fontColor
val metrics = getFontMetrics(font)
val textWidth = metrics.stringWidth(content)
val textHeight = metrics.height
val x = (style.badgeSize.width - textWidth) / 2
val y = (style.badgeSize.height - textHeight) / 2 + metrics.ascent
drawString(
content,
x + style.badgePosition.x,
y + style.badgePosition.y
)
}

/**
Expand Down Expand Up @@ -163,6 +260,40 @@ private fun mouseEventHandler(state: AppState, menu: Menu) =
}
}

/**
* Default data for styling the tray icon.
*
* Dimensions and coordinates are specified in pixels and adjusted for screen [density].
*/
@Suppress("MagicNumber" /* Colors are defined using RGB components. */)
private class TrayStyle(private val density: Density) {
val boxSize = AwtSize(22.adjusted, 22.adjusted)
val iconSize = AwtSize(16.adjusted, 16.adjusted)
val iconPosition = Point(3.adjusted, 3.adjusted)
val badgeSize = AwtSize(17.adjusted, 12.adjusted)
val badgePosition = Point(3.adjusted, 10.adjusted)
val badgeColor = Color(240, 77, 63)
val fontSize = 8.adjusted
val fontName = "San Francisco"
val fontColor = Color.WHITE!!

/**
* Adapts standard pixel value to fit the screen density.
*/
private val Int.adjusted: Int
get() = round(this * density.density).toInt()
}

/**
* Holds a 2D integer size.
MykytaPimonovTD marked this conversation as resolved.
Show resolved Hide resolved
*/
private data class AwtSize(val width: Int, val height: Int) {
/**
* Converts the size to floating-point size.
*/
fun toCompose(): Size = Size(width.toFloat(), height.toFloat())
}

/**
* Displays a popup message near the tray icon.
*/
Expand Down
2 changes: 1 addition & 1 deletion version.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@
/**
* The version of the `Pingh` to publish.
*/
val pinghVersion: String by extra("1.0.0-SNAPSHOT.30")
val pinghVersion: String by extra("1.0.0-SNAPSHOT.31")
Loading