From 16c561b9a186e21210ed751f6ce5468e0a80ec0c Mon Sep 17 00:00:00 2001 From: "Md. Touhidur Rahman" Date: Sun, 14 Sep 2025 16:13:07 +0600 Subject: [PATCH] add badge to display unread messages count, and set flash time to 5s, isolate flash logic in a new class `AlternatingStateManager` --- .../unciv/logic/AlternatingStateManager.kt | 52 +++++++++ .../unciv/logic/multiplayer/chat/ChatStore.kt | 10 +- .../ui/screens/worldscreen/chat/ChatButton.kt | 107 +++++++++++------- .../ui/screens/worldscreen/chat/ChatPopup.kt | 1 - .../status/MultiplayerStatusButton.kt | 60 +++++----- 5 files changed, 151 insertions(+), 79 deletions(-) create mode 100644 core/src/com/unciv/logic/AlternatingStateManager.kt diff --git a/core/src/com/unciv/logic/AlternatingStateManager.kt b/core/src/com/unciv/logic/AlternatingStateManager.kt new file mode 100644 index 0000000000..b3624da95e --- /dev/null +++ b/core/src/com/unciv/logic/AlternatingStateManager.kt @@ -0,0 +1,52 @@ +package com.unciv.logic + +import com.unciv.utils.Concurrency +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +class AlternatingStateManager( + private val name: String, + private val state: State, + private val originalState: (State) -> Unit, + private val alternateState: (State) -> Unit +) { + private var job: Job? = null + + @OptIn(ExperimentalTime::class) + fun start( + duration: Duration = 5.seconds, interval: Duration = 500.milliseconds + ) { + job?.cancel() + job = Concurrency.run(name) { + val startTime = Clock.System.now() + + var isAlternateState = true + while (true) { + if (isAlternateState) { + alternateState(state) + isAlternateState = false + } else { + originalState(state) + isAlternateState = true + } + + if (Clock.System.now() - startTime >= duration) { + originalState(state) + return@run + } + + delay(interval) + } + } + } + + fun stop() { + job?.cancel() + originalState(state) + } +} diff --git a/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt b/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt index e5d6e2b858..f7b5fc10b7 100644 --- a/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt +++ b/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt @@ -12,7 +12,7 @@ import java.util.UUID data class Chat( val gameId: UUID, ) { - var read = true + var unreadCount = 0 // pairs private val messages: MutableList> = mutableListOf(INITIAL_MESSAGE) @@ -100,13 +100,13 @@ object ChatStore { if (gameId.equals(UncivGame.Current.worldScreen?.gameInfo?.gameId?.toUUIDOrNull())) { // ensures that you are not getting notified for your own messages if (UncivGame.Current.worldScreen?.gameInfo?.currentPlayer != incomingChatMsg.civName) { - chat.read = false - UncivGame.Current.worldScreen?.chatButton?.startFlashing() + chat.unreadCount++ + UncivGame.Current.worldScreen?.chatButton?.triggerChatIndication() } } else { // user is out of world screen or // some other game not currently on screen has a message - chat.read = false + chat.unreadCount++ } } } @@ -126,7 +126,7 @@ object ChatStore { Gdx.app.postRunnable { if (civName != "System") { hasGlobalMessage = chatPopup == null - if (hasGlobalMessage) UncivGame.Current.worldScreen?.chatButton?.startFlashing() + if (hasGlobalMessage) UncivGame.Current.worldScreen?.chatButton?.triggerChatIndication() } chatPopup?.addMessage(civName, message, suffix = "one time") diff --git a/core/src/com/unciv/ui/screens/worldscreen/chat/ChatButton.kt b/core/src/com/unciv/ui/screens/worldscreen/chat/ChatButton.kt index ef674a724d..2f9e03e77f 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/chat/ChatButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/chat/ChatButton.kt @@ -2,36 +2,88 @@ package com.unciv.ui.screens.worldscreen.chat import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color -import com.badlogic.gdx.utils.Disposable +import com.badlogic.gdx.utils.Align import com.unciv.UncivGame +import com.unciv.logic.AlternatingStateManager import com.unciv.logic.multiplayer.chat.ChatStore import com.unciv.logic.multiplayer.chat.ChatWebSocket import com.unciv.logic.multiplayer.chat.Message +import com.unciv.ui.components.extensions.disable +import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.input.onClick import com.unciv.ui.images.IconTextButton import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.worldscreen.WorldScreen -import com.unciv.utils.Concurrency -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds class ChatButton(val worldScreen: WorldScreen) : IconTextButton( "Chat", ImageGetter.getImage("OtherIcons/Chat"), 23 -), Disposable { +) { val chat = ChatStore.getChatByGameId(worldScreen.gameInfo.gameId) + val badge = "".toTextButton().apply { + disable() + setColor(Color.valueOf("da1e28")) + label.setColor(Color.WHITE) + label.setAlignment(Align.center) + label.setFontScale(0.2f) + } + + val flash = AlternatingStateManager( + name = "ChatButton color flash", + state = object { + val initialColor = fontColor + val targetColor = Color.ORANGE + }, originalState = { state -> + Gdx.app.postRunnable { + icon?.color = state.initialColor + label.color = state.initialColor + } + }, alternateState = { state -> + Gdx.app.postRunnable { + icon?.color = state.targetColor + label.color = state.targetColor + } + } + ) + + private fun updateBadge(visible: Boolean = false) { + badge.height = height / 3 + badge.setPosition( + width - badge.width / 1.5f, + height - badge.height / 1.5f + ) + + badge.isVisible = visible || chat.unreadCount > 0 || ChatStore.hasGlobalMessage + + if (badge.isVisible) { + var text = chat.unreadCount.toString() + if (ChatStore.hasGlobalMessage) { + text += '+' + } + badge.setText(text) + } + } + + fun triggerChatIndication() { + updateBadge() + flash.start() + } + init { width = 95f iconCell.pad(3f).center() + addActor(badge) - if (!chat.read || ChatStore.hasGlobalMessage) { - startFlashing() + if (chat.unreadCount > 0 || ChatStore.hasGlobalMessage) { + updateBadge(true) + flash.stop() } onClick { - stopFlashing() + chat.unreadCount = 0 + updateBadge() + flash.stop() + ChatPopup(chat, worldScreen).open() } @@ -50,6 +102,7 @@ class ChatButton(val worldScreen: WorldScreen) : IconTextButton( Message.Join(listOf(worldScreen.gameInfo.gameId)), ) updatePosition() + updateBadge() true } else { ChatWebSocket.stop() @@ -61,38 +114,4 @@ class ChatButton(val worldScreen: WorldScreen) : IconTextButton( worldScreen.techPolicyAndDiplomacy.x.coerceAtLeast(1f), worldScreen.techPolicyAndDiplomacy.y - height - 1f ) - - private var flashJob: Job? = null - - fun startFlashing(targetColor: Color = Color.ORANGE, interval: Duration = 500.milliseconds) { - flashJob?.cancel() - flashJob = Concurrency.run("ChatButton color flash") { - var isAlternatingColor = true - while (true) { - Gdx.app.postRunnable { - if (isAlternatingColor) { - icon?.color = targetColor - label.color = targetColor - } else { - icon?.color = fontColor - label.color = fontColor - } - - isAlternatingColor = !isAlternatingColor - } - - delay(interval) - } - } - } - - private fun stopFlashing() { - flashJob?.cancel() - icon?.color = fontColor - label.color = fontColor - } - - override fun dispose() { - flashJob?.cancel() - } } diff --git a/core/src/com/unciv/ui/screens/worldscreen/chat/ChatPopup.kt b/core/src/com/unciv/ui/screens/worldscreen/chat/ChatPopup.kt index a5e55b93a1..6bf74798e4 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/chat/ChatPopup.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/chat/ChatPopup.kt @@ -44,7 +44,6 @@ class ChatPopup( ) init { - chat.read = true ChatStore.chatPopup = this ChatStore.hasGlobalMessage = false chatTable.defaults().growX().pad(5f).center() diff --git a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt index aa5d7f5d40..fb65e82634 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/MultiplayerStatusButton.kt @@ -1,21 +1,22 @@ package com.unciv.ui.screens.worldscreen.status +import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.ui.Button import com.badlogic.gdx.scenes.scene2d.ui.Cell import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup -import com.badlogic.gdx.scenes.scene2d.ui.Image import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame +import com.unciv.logic.AlternatingStateManager import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.HasMultiplayerGameName import com.unciv.logic.multiplayer.MultiplayerGameNameChanged +import com.unciv.logic.multiplayer.MultiplayerGamePreview import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted import com.unciv.logic.multiplayer.MultiplayerGameUpdated -import com.unciv.logic.multiplayer.MultiplayerGamePreview import com.unciv.logic.multiplayer.isUsersTurn import com.unciv.models.translations.tr import com.unciv.ui.components.extensions.setSize @@ -24,20 +25,20 @@ import com.unciv.ui.components.widgets.LoadingImage import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Concurrency -import com.unciv.utils.launchOnGLThread -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds class MultiplayerStatusButton( screen: BaseScreen, curGame: MultiplayerGamePreview? ) : Button(BaseScreen.skin), Disposable { private var curGameName = curGame?.name - private val loadingImage = LoadingImage(style = LoadingImage.Style( - idleImageName = "OtherIcons/Multiplayer", - idleIconColor = Color.WHITE, - minShowTime = 500 - )) + private val loadingImage = LoadingImage( + style = LoadingImage.Style( + idleImageName = "OtherIcons/Multiplayer", + idleIconColor = Color.WHITE, + minShowTime = 500 + ) + ) private val turnIndicator = TurnIndicator() private val turnIndicatorCell: Cell private val gameNamesWithCurrentTurn = getInitialGamesWithCurrentTurn() @@ -96,7 +97,7 @@ class MultiplayerStatusButton( } private fun updateTurnIndicator(flash: Boolean = true) { - if (gameNamesWithCurrentTurn.size == 0) { + if (gameNamesWithCurrentTurn.isEmpty()) { turnIndicatorCell.clearActor() } else { turnIndicatorCell.setActor(turnIndicator) @@ -105,7 +106,7 @@ class MultiplayerStatusButton( // flash so the user sees an better update if (flash) { - turnIndicator.flash() + turnIndicator.flash.start(3.seconds) } } @@ -118,10 +119,9 @@ class MultiplayerStatusButton( private class TurnIndicator : HorizontalGroup(), Disposable { val gameAmount = Label("2", BaseScreen.skin) - val image: Image - private var job: Job? = null + val image = ImageGetter.getImage("OtherIcons/ExclamationMark") + init { - image = ImageGetter.getImage("OtherIcons/ExclamationMark") image.setSize(30f) addActor(image) } @@ -135,23 +135,25 @@ private class TurnIndicator : HorizontalGroup(), Disposable { } } - fun flash() { - // using a gdx Action would be nicer, but we don't necessarily have continuousRendering on and we still want to flash - flash(6, Color.WHITE, Color.ORANGE) - } - private fun flash(alternations: Int, curColor: Color, nextColor: Color) { - if (alternations == 0) return - gameAmount.color = nextColor - image.color = nextColor - job = Concurrency.run("StatusButton color flash") { - delay(500) - launchOnGLThread { - flash(alternations - 1, nextColor, curColor) + val flash = AlternatingStateManager( + name = "StatusButton color flash", + state = object { + val initialColor = Color.WHITE + val targetColor = Color.ORANGE + }, originalState = { state -> + Gdx.app.postRunnable { + image.color = state.initialColor + gameAmount.color = state.initialColor + } + }, alternateState = { state -> + Gdx.app.postRunnable { + image.color = state.targetColor + gameAmount.color = state.targetColor } } - } + ) override fun dispose() { - job?.cancel() + flash.stop() } }