From ea829d65eb37de2e85600d3cfaf4e9cbb8c60c3f Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Sun, 19 Jan 2025 23:15:36 -0600 Subject: [PATCH 01/49] Begin work on implementing chat. --- .../OxGames/Pluvia/service/SteamService.kt | 39 ++-- .../Pluvia/service/SteamUnifiedFriends.kt | 169 ++++++++++++++++++ 2 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 7ee5acd7..abca6024 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -60,9 +60,7 @@ import `in`.dragonbra.javasteam.enums.EPaymentMethod import `in`.dragonbra.javasteam.enums.EPersonaState import `in`.dragonbra.javasteam.enums.EResult import `in`.dragonbra.javasteam.networking.steam3.ProtocolTypes -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient.CChat_RequestFriendPersonaStates_Request import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation -import `in`.dragonbra.javasteam.rpc.service.Chat import `in`.dragonbra.javasteam.steam.authentication.AuthPollResult import `in`.dragonbra.javasteam.steam.authentication.AuthSessionDetails import `in`.dragonbra.javasteam.steam.authentication.AuthenticationException @@ -86,7 +84,6 @@ import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.PersonaStat import `in`.dragonbra.javasteam.steam.handlers.steamgameserver.SteamGameServer import `in`.dragonbra.javasteam.steam.handlers.steammasterserver.SteamMasterServer import `in`.dragonbra.javasteam.steam.handlers.steamscreenshots.SteamScreenshots -import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages import `in`.dragonbra.javasteam.steam.handlers.steamuser.LogOnDetails import `in`.dragonbra.javasteam.steam.handlers.steamuser.SteamUser import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOffCallback @@ -149,16 +146,14 @@ class SteamService : Service(), IChallengeUrlChanged { private lateinit var notificationHelper: NotificationHelper - private var _callbackManager: CallbackManager? = null - + internal var callbackManager: CallbackManager? = null internal var steamClient: SteamClient? = null + private var _unifiedFriends: SteamUnifiedFriends? = null private var _steamUser: SteamUser? = null private var _steamApps: SteamApps? = null private var _steamFriends: SteamFriends? = null private var _steamCloud: SteamCloud? = null - private var _unifiedMessages: SteamUnifiedMessages? = null - private var _unifiedChat: Chat? = null private val _callbackSubscriptions: ArrayList = ArrayList() @@ -368,7 +363,7 @@ class SteamService : Service(), IChallengeUrlChanged { SplitInstallSessionStatus.INSTALLING, SplitInstallSessionStatus.DOWNLOADED, SplitInstallSessionStatus.DOWNLOADING, - -> { + -> { if (!isActive) { Timber.i("ubuntufs module download cancelling due to scope becoming inactive") splitManager.requestCancelInstall(moduleInstallSessionId) @@ -1051,8 +1046,7 @@ class SteamService : Service(), IChallengeUrlChanged { steamClient!!.removeHandler(SteamUserStats::class.java) // create the callback manager which will route callbacks to function calls - _callbackManager = CallbackManager(steamClient!!) - _unifiedMessages = steamClient!!.getHandler() + callbackManager = CallbackManager(steamClient!!) // get the different handlers to be used throughout the service _steamUser = steamClient!!.getHandler(SteamUser::class.java) @@ -1060,9 +1054,11 @@ class SteamService : Service(), IChallengeUrlChanged { _steamFriends = steamClient!!.getHandler(SteamFriends::class.java) _steamCloud = steamClient!!.getHandler(SteamCloud::class.java) + _unifiedFriends = SteamUnifiedFriends(this) + // subscribe to the callbacks we are interested in with(_callbackSubscriptions) { - with(_callbackManager!!) { + with(callbackManager!!) { add(subscribe(ConnectedCallback::class.java, ::onConnected)) add(subscribe(DisconnectedCallback::class.java, ::onDisconnected)) add(subscribe(LoggedOnCallback::class.java, ::onLoggedOn)) @@ -1087,7 +1083,7 @@ class SteamService : Service(), IChallengeUrlChanged { // logD("runWaitCallbacks") try { - _callbackManager!!.runWaitCallbacks(1000L) + callbackManager!!.runWaitCallbacks(1000L) } catch (e: Exception) { Timber.e("runWaitCallbacks failed: $e") } @@ -1187,10 +1183,10 @@ class SteamService : Service(), IChallengeUrlChanged { } _callbackSubscriptions.clear() - _callbackManager = null + callbackManager = null - _unifiedMessages = null - _unifiedChat = null + _unifiedFriends?.close() + _unifiedFriends = null packageInfo.clear() appInfo.clear() @@ -1259,14 +1255,6 @@ class SteamService : Service(), IChallengeUrlChanged { steamClient!!.disconnect() } - /** - * Request a fresh state of Friend's PersonaStates - */ - private fun refreshPersonaStates() { - val request = CChat_RequestFriendPersonaStates_Request.newBuilder().build() - _unifiedChat?.requestFriendPersonaStates(request) - } - private fun onLoggedOn(callback: LoggedOnCallback) { Timber.i("Logged onto Steam: ${callback.result}") @@ -1281,9 +1269,6 @@ class SteamService : Service(), IChallengeUrlChanged { // servers from the Steam Directory. PrefManager.cellId = callback.cellID - // Create Unified Handlers - _unifiedChat = _unifiedMessages!!.createService(Chat::class.java) - // retrieve persona data of logged in user serviceScope.launch { requestUserPersona() @@ -1393,7 +1378,7 @@ class SteamService : Service(), IChallengeUrlChanged { // NOTE: Our UI could load too quickly on fresh database, our icon will be "?" // unless relaunched or we nav to a new screen. - refreshPersonaStates() + _unifiedFriends?.refreshPersonaStates() } } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt new file mode 100644 index 00000000..46cbe2b8 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -0,0 +1,169 @@ +package com.OxGames.Pluvia.service + +import `in`.dragonbra.javasteam.enums.EChatEntryType +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient.CChat_RequestFriendPersonaStates_Request +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFriendmessagesSteamclient +import `in`.dragonbra.javasteam.rpc.service.Chat +import `in`.dragonbra.javasteam.rpc.service.FriendMessages +import `in`.dragonbra.javasteam.rpc.service.FriendMessagesClient +import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages +import `in`.dragonbra.javasteam.types.SteamID +import timber.log.Timber +import java.io.Closeable + +// For chat ideas, check out: +// https://github.com/marwaniaaj/RichLinksJetpackCompose/tree/main +// https://blog.stackademic.com/rick-link-representation-in-jetpack-compose-d33956e8719e +// https://github.com/lukasroberts/AndroidLinkView +// https://github.com/Aldikitta/JetComposeChatWithMe +// https://github.com/android/compose-samples/tree/main/Jetchat +// https://github.com/LossyDragon/Vapulla + +typealias AckMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.Builder +typealias IncomingMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_IncomingMessage_Notification.Builder + +class SteamUnifiedFriends(service: SteamService) : AutoCloseable { + + private val callbackSubscriptions: ArrayList = ArrayList() + + private var unifiedMessages: SteamUnifiedMessages? = null + + private var chat: Chat? = null + + private var messages: FriendMessages? = null + + // TODO OfflineMessageNotificationCallback ? + // TODO FriendMsgEchoCallback ? + // TODO EmoticonListCallback ? + + init { + unifiedMessages = service.steamClient!!.getHandler() + chat = unifiedMessages!!.createService(Chat::class.java) + messages = unifiedMessages!!.createService(FriendMessages::class.java) + + service.callbackManager!!.subscribeServiceNotification { + Timber.i("Ack-ing Message") + // it.body.steamidPartner + // TODO: 'read' a message since another client has opened the chat. + }.also(callbackSubscriptions::add) + + service.callbackManager!!.subscribeServiceNotification { + Timber.i("Incoming Message") + when (it.body.chatEntryType) { + EChatEntryType.Typing.code() -> { + // TODO: An incoming chat message is being typed up. + } + + EChatEntryType.ChatMsg.code() -> { + // TODO: An incoming chat message has been received. + } + + else -> Timber.w("Unknown incoming message, ${EChatEntryType.from(it.body.chatEntryType)}") + } + }.also(callbackSubscriptions::add) + } + + override fun close() { + unifiedMessages = null + chat = null + messages = null + + callbackSubscriptions.forEach { + it.close() + } + } + + /** + * Request a fresh state of Friend's PersonaStates + */ + fun refreshPersonaStates() { + val request = CChat_RequestFriendPersonaStates_Request.newBuilder().build() + chat?.requestFriendPersonaStates(request) + } + + suspend fun getRecentMessages(friendID: SteamID) { + Timber.i("Getting Recent messages for: ${friendID.convertToUInt64()}") + + val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_GetRecentMessages_Request.newBuilder().apply { + steamid1 = SteamService.userSteamId!!.convertToUInt64() + steamid2 = friendID.convertToUInt64() + count = 50 + rtime32StartTime = 0 + bbcodeFormat = true + startOrdinal = 0 + timeLast = Int.MAX_VALUE // More explicit than magic number + ordinalLast = 0 + }.build() + + val response = messages!!.getRecentMessages(request).await() + + if (response.result != EResult.OK) { + Timber.w("Failed to get message history for friend: ${friendID.convertToUInt64()}, ${response.result}") + return + } + + // TODO: Insert new messages into database + // TODO: Do not dupe messages + } + + suspend fun setIsTyping(friendID: SteamID) { + Timber.i("Sending 'is typing' to ${friendID.convertToUInt64()}") + val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { + chatEntryType = EChatEntryType.Typing.code() + message = "" + steamid = friendID.convertToUInt64() + }.build() + + val response = messages!!.sendMessage(request).await() + + if (response.result != EResult.OK) { + Timber.w("Failed to send typing message to friend: ${friendID.convertToUInt64()}, ${response.result}") + return + } + + // TODO: This, I believe returns a result with supplemental data to append to the database. + } + + suspend fun sendMessage(friendID: SteamID, chatMessage: String) { + Timber.i("Sending chat message to ${friendID.convertToUInt64()}") + val trimmedMessage = chatMessage.trim() + + if (trimmedMessage.isEmpty()) { + Timber.w("Trying to send an empty message.") + return + } + + val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { + chatEntryType = EChatEntryType.ChatMsg.code() + message = chatMessage + steamid = friendID.convertToUInt64() + containsBbcode = true + echoToSender = false + lowPriority = false + }.build() + + val response = messages!!.sendMessage(request).await() + + if (response.result != EResult.OK) { + Timber.w("Failed to send chat message to friend: ${friendID.convertToUInt64()}, ${response.result}") + return + } + + // TODO: This, I believe returns a result with supplemental data to append to the database. + // TODO: We also need to append the message to our database + + // Once chat notifications are implemented, we should clear it here as well. + } + + fun ackMessage(friendID: SteamID) { + Timber.d("Ack-ing message for friend: ${friendID.convertToUInt64()}") + val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.newBuilder().apply { + steamidPartner = friendID.convertToUInt64() + timestamp = System.currentTimeMillis().div(1000).toInt() + }.build() + + // This does not return anything. + messages!!.ackMessage(request) + } +} From 9bd21f8792cfcbea9ed47e14999b15121a137468 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 20 Jan 2025 16:58:20 -0600 Subject: [PATCH 02/49] A little more work on chat. --- .../com/OxGames/Pluvia/data/SteamFriend.kt | 6 ++ .../OxGames/Pluvia/data/SteamFriendMessage.kt | 17 ++++ .../Pluvia/service/SteamUnifiedFriends.kt | 89 +++++++++++++++++-- 3 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt index 8d745c50..c762f38d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt +++ b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt @@ -69,6 +69,12 @@ data class SteamFriend( val clanTag: String = "", @ColumnInfo("online_session_instances") val onlineSessionInstances: Int = 0, + + // Chat message + @ColumnInfo("chat_entry_type") + val isTyping: Boolean = false, + @ColumnInfo("unread_messages") + val unreadMessageCount: Int = 0, ) { val isOnline: Boolean get() = (state.code() in 1..6) diff --git a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt new file mode 100644 index 00000000..6c8fbc08 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt @@ -0,0 +1,17 @@ +package com.OxGames.Pluvia.data + +import `in`.dragonbra.javasteam.enums.EChatEntryType +import `in`.dragonbra.javasteam.types.SteamID + +data class SteamFriendMessage( + val steamID: SteamID, + val chatEntryType: EChatEntryType, + val message: String, + val containsBBCode: Boolean, + val echoToSender: Boolean, + val lowPriority: Boolean, + val serverTimeStamp: Int, + val clientMessageID: Int = 0, + val lastMessage: Int = 0, + val lastView: Int = 0, +) diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index 46cbe2b8..46ffacb4 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -86,13 +86,14 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { Timber.i("Getting Recent messages for: ${friendID.convertToUInt64()}") val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_GetRecentMessages_Request.newBuilder().apply { - steamid1 = SteamService.userSteamId!!.convertToUInt64() - steamid2 = friendID.convertToUInt64() + steamid1 = SteamService.userSteamId!!.convertToUInt64() // You + steamid2 = friendID.convertToUInt64() // Friend + // The rest here and below is what steam has looking at NHA2 count = 50 rtime32StartTime = 0 bbcodeFormat = true startOrdinal = 0 - timeLast = Int.MAX_VALUE // More explicit than magic number + timeLast = Int.MAX_VALUE ordinalLast = 0 }.build() @@ -105,14 +106,27 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { // TODO: Insert new messages into database // TODO: Do not dupe messages + response.body.messagesList.forEach { message -> + // message.accountid + // message.timestamp + // message.message + // message.ordinal + // message.reactionsList.forEach { reaction -> + // reaction.reaction + // reaction.reactionType + // reaction.reactionBytes + // reaction.reactorsList + // reaction.reactorsCount + // } + } + Timber.i("More available: ${response.body.moreAvailable}") } suspend fun setIsTyping(friendID: SteamID) { Timber.i("Sending 'is typing' to ${friendID.convertToUInt64()}") val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { - chatEntryType = EChatEntryType.Typing.code() - message = "" steamid = friendID.convertToUInt64() + chatEntryType = EChatEntryType.Typing.code() }.build() val response = messages!!.sendMessage(request).await() @@ -123,6 +137,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { } // TODO: This, I believe returns a result with supplemental data to append to the database. + // response.body.serverTimestamp } suspend fun sendMessage(friendID: SteamID, chatMessage: String) { @@ -153,6 +168,8 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { // TODO: This, I believe returns a result with supplemental data to append to the database. // TODO: We also need to append the message to our database + // response.body.serverTimestamp + // Once chat notifications are implemented, we should clear it here as well. } @@ -166,4 +183,66 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { // This does not return anything. messages!!.ackMessage(request) } + + suspend fun getActiveMessageSessions() { + Timber.i("Get Active message sessions") + + val request = SteammessagesFriendmessagesSteamclient.CFriendsMessages_GetActiveMessageSessions_Request.newBuilder().apply { + lastmessageSince = 0 + onlySessionsWithMessages = true + }.build() + + val response = messages!!.getActiveMessageSessions(request).await() + + if (response.result != EResult.OK) { + Timber.w("Failed to get active message sessions, ${response.result}") + return + } + + // TODO + + // response.body.timestamp + + response.body.messageSessionsList.forEach { session -> + // session.accountidFriend + // session.lastMessage + // session.lastView + // session.unreadMessageCount + } + } + + // suspend fun getPerFriendPreferences() + + suspend fun updateMessageReaction( + friendID: SteamID, + serverTimestamp: Int, + reactionType: SteammessagesFriendmessagesSteamclient.EMessageReactionType, + reaction: String, + isAdd: Boolean, + ) { + Timber.i( + "Update message reaction: ${friendID.convertToUInt64()}, timestamp: $serverTimestamp, " + + "type: $reactionType, reaction: $reaction, isAdd: $isAdd ", + ) + + val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_UpdateMessageReaction_Request.newBuilder().apply { + this.steamid = friendID.convertToUInt64() + this.serverTimestamp = serverTimestamp + this.ordinal = 0 + this.reactionType = reactionType + this.reaction = reaction + this.isAdd = isAdd + }.build() + + val response = messages!!.updateMessageReaction(request).await() + + if (response.result != EResult.OK) { + Timber.w("Failed to get message reaction, ${response.result}") + return + } + + response.body.reactorsList.forEach { reactor -> + // Last part of steamID3 + } + } } From ecfb61b877f8e492d0bdf5d3c6573f272d6c1604 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 21 Jan 2025 00:05:38 -0600 Subject: [PATCH 03/49] Begin work in UI components for chat. --- .../main/java/com/OxGames/Pluvia/Constants.kt | 1 + .../com/OxGames/Pluvia/data/FriendMessage.kt | 16 + .../OxGames/Pluvia/data/SteamFriendMessage.kt | 17 -- .../com/OxGames/Pluvia/db/PluviaDatabase.kt | 8 +- .../Pluvia/db/dao/FriendMessagesDao.kt | 37 +++ .../com/OxGames/Pluvia/di/DatabaseModule.kt | 4 + .../OxGames/Pluvia/service/SteamService.kt | 6 +- .../Pluvia/service/SteamUnifiedFriends.kt | 82 +++++- .../ui/screen/friends/ChatMessageItem.kt | 105 +++++++ .../Pluvia/ui/screen/friends/ChatScreen.kt | 276 ++++++++++++++++++ .../com/OxGames/Pluvia/utils/StringUtils.kt | 3 + 11 files changed, 522 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt delete mode 100644 app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatMessageItem.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/Constants.kt b/app/src/main/java/com/OxGames/Pluvia/Constants.kt index d6d1bd56..81b6fde1 100644 --- a/app/src/main/java/com/OxGames/Pluvia/Constants.kt +++ b/app/src/main/java/com/OxGames/Pluvia/Constants.kt @@ -20,6 +20,7 @@ object Constants { object Persona { const val AVATAR_BASE_URL = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/" const val MISSING_AVATAR_URL = "${AVATAR_BASE_URL}fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg" + const val PROFILE_URL = "https://steamcommunity.com/profiles/" } object Misc { diff --git a/app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt b/app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt new file mode 100644 index 00000000..8817f847 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt @@ -0,0 +1,16 @@ +package com.OxGames.Pluvia.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity("chat_message") +data class FriendMessage( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0L, + @ColumnInfo(name = "steam_id_friend") val steamIDFriend: Long, + @ColumnInfo(name = "from_local") val fromLocal: Boolean, + @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "low_priority") val lowPriority: Boolean, + @ColumnInfo(name = "timestamp") val timestamp: Int, +) diff --git a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt deleted file mode 100644 index 6c8fbc08..00000000 --- a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriendMessage.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.OxGames.Pluvia.data - -import `in`.dragonbra.javasteam.enums.EChatEntryType -import `in`.dragonbra.javasteam.types.SteamID - -data class SteamFriendMessage( - val steamID: SteamID, - val chatEntryType: EChatEntryType, - val message: String, - val containsBBCode: Boolean, - val echoToSender: Boolean, - val lowPriority: Boolean, - val serverTimeStamp: Int, - val clientMessageID: Int = 0, - val lastMessage: Int = 0, - val lastView: Int = 0, -) diff --git a/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt b/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt index 326f6ab6..5f7cfb44 100644 --- a/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt +++ b/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt @@ -5,6 +5,7 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.OxGames.Pluvia.data.ChangeNumbers import com.OxGames.Pluvia.data.FileChangeLists +import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.db.converters.ByteArrayConverter import com.OxGames.Pluvia.db.converters.FriendConverter @@ -12,13 +13,14 @@ import com.OxGames.Pluvia.db.converters.PathTypeConverter import com.OxGames.Pluvia.db.converters.UserFileInfoListConverter import com.OxGames.Pluvia.db.dao.ChangeNumbersDao import com.OxGames.Pluvia.db.dao.FileChangeListsDao +import com.OxGames.Pluvia.db.dao.FriendMessagesDao import com.OxGames.Pluvia.db.dao.SteamFriendDao const val DATABASE_NAME = "pluvia.db" @Database( - entities = [SteamFriend::class, ChangeNumbers::class, FileChangeLists::class], - version = 1, + entities = [SteamFriend::class, ChangeNumbers::class, FileChangeLists::class, FriendMessage::class], + version = 2, exportSchema = false, // Should export once stable. ) @TypeConverters( @@ -34,4 +36,6 @@ abstract class PluviaDatabase : RoomDatabase() { abstract fun appChangeNumbersDao(): ChangeNumbersDao abstract fun appFileChangeListsDao(): FileChangeListsDao + + abstract fun friendMessagesDao(): FriendMessagesDao } diff --git a/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt b/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt new file mode 100644 index 00000000..3075df07 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt @@ -0,0 +1,37 @@ +package com.OxGames.Pluvia.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import com.OxGames.Pluvia.data.FriendMessage +import `in`.dragonbra.javasteam.types.SteamID +import kotlinx.coroutines.flow.Flow + +@Dao +interface FriendMessagesDao { + @Insert + suspend fun insertMessage(message: FriendMessage) + + @Insert + suspend fun insertMessages(messages: List) + + @Delete + suspend fun deleteMessage(message: FriendMessage) + + @Query("DELETE FROM chat_message") + suspend fun deleteAllMessages() + + @Query("DELETE FROM chat_message WHERE steam_id_friend = :steamId") + suspend fun deleteAllMessagesForFriend(steamId: SteamID) + + @Query("SELECT * FROM chat_message WHERE steam_id_friend = :steamId ORDER BY timestamp DESC") + fun getAllMessagesForFriend(steamId: SteamID): Flow> + + @Update + suspend fun updateMessage(message: FriendMessage) + + @Query("SELECT COUNT(*) FROM chat_message WHERE steam_id_friend = :steamId") + fun getMessageCountForFriend(steamId: SteamID): Flow +} diff --git a/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt b/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt index 97c65849..0c9c2e24 100644 --- a/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt +++ b/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt @@ -36,4 +36,8 @@ class DatabaseModule { @Provides @Singleton fun provideAppFileChangeListsDao(db: PluviaDatabase) = db.appFileChangeListsDao() + + @Provides + @Singleton + fun provideFriendMessages(db: PluviaDatabase) = db.friendMessagesDao() } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index abca6024..7f9c941d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -28,6 +28,7 @@ import com.OxGames.Pluvia.data.UserFileInfo import com.OxGames.Pluvia.db.PluviaDatabase import com.OxGames.Pluvia.db.dao.ChangeNumbersDao import com.OxGames.Pluvia.db.dao.FileChangeListsDao +import com.OxGames.Pluvia.db.dao.FriendMessagesDao import com.OxGames.Pluvia.db.dao.SteamFriendDao import com.OxGames.Pluvia.enums.AppType import com.OxGames.Pluvia.enums.ControllerSupport @@ -135,6 +136,9 @@ class SteamService : Service(), IChallengeUrlChanged { @Inject lateinit var friendDao: SteamFriendDao + @Inject + lateinit var messagesDao: FriendMessagesDao + @Inject lateinit var changeNumbersDao: ChangeNumbersDao @@ -363,7 +367,7 @@ class SteamService : Service(), IChallengeUrlChanged { SplitInstallSessionStatus.INSTALLING, SplitInstallSessionStatus.DOWNLOADED, SplitInstallSessionStatus.DOWNLOADING, - -> { + -> { if (!isActive) { Timber.i("ubuntufs module download cancelling due to scope becoming inactive") splitManager.requestCancelInstall(moduleInstallSessionId) diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index 46ffacb4..909fd773 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -1,5 +1,9 @@ package com.OxGames.Pluvia.service +import androidx.room.withTransaction +import com.OxGames.Pluvia.data.FriendMessage +import com.OxGames.Pluvia.db.dao.FriendMessagesDao +import com.OxGames.Pluvia.db.dao.SteamFriendDao import `in`.dragonbra.javasteam.enums.EChatEntryType import `in`.dragonbra.javasteam.enums.EResult import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient.CChat_RequestFriendPersonaStates_Request @@ -9,8 +13,12 @@ import `in`.dragonbra.javasteam.rpc.service.FriendMessages import `in`.dragonbra.javasteam.rpc.service.FriendMessagesClient import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages import `in`.dragonbra.javasteam.types.SteamID -import timber.log.Timber import java.io.Closeable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber // For chat ideas, check out: // https://github.com/marwaniaaj/RichLinksJetpackCompose/tree/main @@ -20,10 +28,26 @@ import java.io.Closeable // https://github.com/android/compose-samples/tree/main/Jetchat // https://github.com/LossyDragon/Vapulla +// TODO +// ----- CHAT CHECKLIST --- +// 1. Implement chat functionality, including sending and receiving messages life. +// 2. Implement getting chat message history. +// 3. Implement rich preview +// 4. Implement Stickers and Emoticons +// 5. Implement Reactions + typealias AckMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.Builder typealias IncomingMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_IncomingMessage_Notification.Builder -class SteamUnifiedFriends(service: SteamService) : AutoCloseable { +class SteamUnifiedFriends( + private val service: SteamService, +) : AutoCloseable { + + private val messagesDao: FriendMessagesDao + get() = service.messagesDao + + private val friendDao: SteamFriendDao + get() = service.friendDao private val callbackSubscriptions: ArrayList = ArrayList() @@ -31,7 +55,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { private var chat: Chat? = null - private var messages: FriendMessages? = null + private var friendMessages: FriendMessages? = null // TODO OfflineMessageNotificationCallback ? // TODO FriendMsgEchoCallback ? @@ -40,7 +64,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { init { unifiedMessages = service.steamClient!!.getHandler() chat = unifiedMessages!!.createService(Chat::class.java) - messages = unifiedMessages!!.createService(FriendMessages::class.java) + friendMessages = unifiedMessages!!.createService(FriendMessages::class.java) service.callbackManager!!.subscribeServiceNotification { Timber.i("Ack-ing Message") @@ -52,11 +76,43 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { Timber.i("Incoming Message") when (it.body.chatEntryType) { EChatEntryType.Typing.code() -> { - // TODO: An incoming chat message is being typed up. + CoroutineScope(Dispatchers.IO).launch { + service.db.withTransaction { + val friend = friendDao.findFriend(it.body.steamidFriend).first() + + if (friend == null) { + Timber.w("Unable to find friend ${it.body.steamidFriend}") + return@withTransaction + } + + friendDao.update(friend.copy(isTyping = true)) + } + } } EChatEntryType.ChatMsg.code() -> { - // TODO: An incoming chat message has been received. + CoroutineScope(Dispatchers.IO).launch { + service.db.withTransaction { + val friend = friendDao.findFriend(it.body.steamidFriend).first() + + if (friend == null) { + Timber.w("Unable to find friend ${it.body.steamidFriend}") + return@withTransaction + } + + friendDao.update(friend.copy(isTyping = false)) + + val chatMsg = FriendMessage( + steamIDFriend = it.body.steamidFriend, + fromLocal = false, + message = it.body.message, + timestamp = it.body.rtime32ServerTimestamp, + lowPriority = it.body.lowPriority, + ) + + messagesDao.insertMessage(chatMsg) + } + } } else -> Timber.w("Unknown incoming message, ${EChatEntryType.from(it.body.chatEntryType)}") @@ -67,7 +123,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { override fun close() { unifiedMessages = null chat = null - messages = null + friendMessages = null callbackSubscriptions.forEach { it.close() @@ -97,7 +153,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { ordinalLast = 0 }.build() - val response = messages!!.getRecentMessages(request).await() + val response = friendMessages!!.getRecentMessages(request).await() if (response.result != EResult.OK) { Timber.w("Failed to get message history for friend: ${friendID.convertToUInt64()}, ${response.result}") @@ -129,7 +185,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { chatEntryType = EChatEntryType.Typing.code() }.build() - val response = messages!!.sendMessage(request).await() + val response = friendMessages!!.sendMessage(request).await() if (response.result != EResult.OK) { Timber.w("Failed to send typing message to friend: ${friendID.convertToUInt64()}, ${response.result}") @@ -158,7 +214,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { lowPriority = false }.build() - val response = messages!!.sendMessage(request).await() + val response = friendMessages!!.sendMessage(request).await() if (response.result != EResult.OK) { Timber.w("Failed to send chat message to friend: ${friendID.convertToUInt64()}, ${response.result}") @@ -181,7 +237,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { }.build() // This does not return anything. - messages!!.ackMessage(request) + friendMessages!!.ackMessage(request) } suspend fun getActiveMessageSessions() { @@ -192,7 +248,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { onlySessionsWithMessages = true }.build() - val response = messages!!.getActiveMessageSessions(request).await() + val response = friendMessages!!.getActiveMessageSessions(request).await() if (response.result != EResult.OK) { Timber.w("Failed to get active message sessions, ${response.result}") @@ -234,7 +290,7 @@ class SteamUnifiedFriends(service: SteamService) : AutoCloseable { this.isAdd = isAdd }.build() - val response = messages!!.updateMessageReaction(request).await() + val response = friendMessages!!.updateMessageReaction(request).await() if (response.result != EResult.OK) { Timber.w("Failed to get message reaction, ${response.result}") diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatMessageItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatMessageItem.kt new file mode 100644 index 00000000..29be5c7e --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatMessageItem.kt @@ -0,0 +1,105 @@ +package com.OxGames.Pluvia.ui.screen.friends + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.OxGames.Pluvia.ui.theme.PluviaTheme + +@Composable +fun ChatBubble( + message: String, + timestamp: String, + fromLocal: Boolean, + modifier: Modifier = Modifier, + bubbleColor: Color = if (fromLocal) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + textColor: Color = if (fromLocal) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondary, + timestampColor: Color = MaterialTheme.colorScheme.surfaceVariant, + bubbleShape: Shape = RoundedCornerShape(16.dp), + maxWidth: Dp = 280.dp, + contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalAlignment = if (fromLocal) Alignment.End else Alignment.Start, + ) { + Box( + modifier = Modifier + .widthIn(max = maxWidth) + .background( + color = bubbleColor, + shape = bubbleShape, + ), + ) { + Column(modifier = Modifier.padding(contentPadding)) { + // The message + Text( + modifier = Modifier.align(if (fromLocal) Alignment.End else Alignment.Start), + text = message, + color = textColor, + style = MaterialTheme.typography.bodyLarge, + ) + + // The time + Text( + text = timestamp, + color = timestampColor, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .align(Alignment.End) + .padding(top = 6.dp), + ) + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +fun ChatBubblePreview() { + PluviaTheme { + Surface { + Column { + ChatBubble( + message = "Hey", + timestamp = "Jan 00 - 00:00 PM", + fromLocal = true, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ChatBubble( + message = ":O!!!", + timestamp = "Jan 00 - 00:00 PM", + fromLocal = false, + ) + + ChatBubble( + message = "Wow very cool, we should play a game together sometime! How does Team Fortress 2 sound?", + timestamp = "Jan 00 - 00:00 PM", + fromLocal = false, + ) + } + } + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt new file mode 100644 index 00000000..9d3b3ca4 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt @@ -0,0 +1,276 @@ +package com.OxGames.Pluvia.ui.screen.friends + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.OxGames.Pluvia.data.FriendMessage +import com.OxGames.Pluvia.data.SteamFriend +import com.OxGames.Pluvia.db.dao.FriendMessagesDao +import com.OxGames.Pluvia.db.dao.SteamFriendDao +import com.OxGames.Pluvia.ui.component.topbar.BackButton +import com.OxGames.Pluvia.ui.theme.PluviaTheme +import com.OxGames.Pluvia.ui.util.ListItemImage +import com.OxGames.Pluvia.utils.getAvatarURL +import com.OxGames.Pluvia.utils.getProfileUrl +import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.dragonbra.javasteam.enums.EPersonaState +import `in`.dragonbra.javasteam.enums.EPersonaStateFlag +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class ChatState( + val friend: SteamFriend = SteamFriend(0), + val messages: List = listOf(), +) + +@HiltViewModel +class ChatViewModel @Inject constructor( + friendDao: SteamFriendDao, + messagesDao: FriendMessagesDao, +) : ViewModel() { + + private val _chatState = MutableStateFlow(ChatState()) + val chatState: StateFlow = _chatState.asStateFlow() +} + +@Composable +fun ChatScreen( + viewModel: ChatViewModel = hiltViewModel(), + onBack: () -> Unit, +) { + val state by viewModel.chatState.collectAsStateWithLifecycle() + + ChatScreenContent( + steamFriend = state.friend, + messages = state.messages, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatScreenContent( + steamFriend: SteamFriend, + messages: List, + onBack: () -> Unit, +) { + val snackbarHost = remember { SnackbarHostState() } + var expanded by remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current + val scrollState = rememberLazyListState() + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHost) }, + topBar = { + CenterAlignedTopAppBar( + title = { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + ListItemImage( + image = { steamFriend.avatarHash.getAvatarURL() }, + size = 40.dp, + contentDescription = "Logged in account user profile", + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column { + CompositionLocalProvider( + LocalContentColor provides steamFriend.statusColor, + LocalTextStyle provides TextStyle( + lineHeight = 1.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + ) { + Text( + overflow = TextOverflow.Ellipsis, + fontSize = 20.sp, + maxLines = 1, + text = buildAnnotatedString { + append(steamFriend.nameOrNickname) + if (steamFriend.statusIcon != null) { + append(" ") + appendInlineContent("icon", "[icon]") + } + }, + inlineContent = mapOf( + "icon" to InlineTextContent( + Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + steamFriend.statusIcon?.let { + Icon(imageVector = it, tint = Color.LightGray, contentDescription = it.name) + } + }, + ), + ), + ) + + Text( + text = if (steamFriend.isPlayingGame) { + // TODO get game names + steamFriend.gameName.ifEmpty { "Playing game id: ${steamFriend.gameAppID}" } + } else { + steamFriend.state.name + }, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + maxLines = 1, + color = LocalContentColor.current.copy(alpha = .75f), + ) + } + } + } + }, + navigationIcon = { + BackButton(onClick = onBack) + }, + actions = { + Box { + IconButton( + onClick = { expanded = !expanded }, + content = { Icon(imageVector = Icons.Default.MoreVert, contentDescription = null) }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(text = "View Profile") }, + onClick = { uriHandler.openUri(steamFriend.id.getProfileUrl()) }, + ) + DropdownMenuItem( + text = { Text(text = "View Previous Names") }, + onClick = { TODO() }, + ) + DropdownMenuItem( + text = { Text(text = "More Settings") }, + onClick = { + // TODO() + // 3. Friend settings: + // 3a. Add to favorites + // 3b. Block communication + // 3c. Friend (specific) notification settings + }, + ) + } + } + }, + ) + }, + ) { paddingValues -> + val sfd = remember { + SimpleDateFormat("MMM d - h:mm a", Locale.getDefault()).apply { + timeZone = TimeZone.getDefault() + } + } + + // TODO Typing bar + Send + Emoji selector + // TODO scroll to bottom + // TODO scroll to bottom if we're ~3 messages slightly scrolled. + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .imePadding(), + state = scrollState, + ) { + items(messages, key = { it.id }) { msg -> + ChatBubble( + message = msg.message, + timestamp = sfd.format(msg.timestamp * 1000L), + fromLocal = msg.fromLocal, + ) + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_ChatScreenContent() { + PluviaTheme { + ChatScreenContent( + steamFriend = SteamFriend( + id = 76561198003805806, + state = EPersonaState.Online, + avatarHash = "cfc54391f2f2ba745b701ad1287f73e50dc26d74", + name = "Lossy", + nickname = "Lossy with a nickname which should clip", + gameAppID = 440, + stateFlags = EPersonaStateFlag.from(2048), + ), + messages = List(20) { + FriendMessage( + id = it.plus(1).toLong(), + steamIDFriend = 76561198003805806, + fromLocal = it % 3 == 0, + message = "Hey!, ".repeat(it.plus(1).times(1)), + lowPriority = false, + timestamp = 1737438789, + ) + }, + onBack = { }, + ) + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/StringUtils.kt b/app/src/main/java/com/OxGames/Pluvia/utils/StringUtils.kt index fdb24e70..8e327292 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/StringUtils.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/StringUtils.kt @@ -11,3 +11,6 @@ fun String.getAvatarURL(): String = ?.takeIf { str -> str.isNotEmpty() && !str.all { it == '0' } } ?.let { "${Constants.Persona.AVATAR_BASE_URL}${it.substring(0, 2)}/${it}_full.jpg" } ?: Constants.Persona.MISSING_AVATAR_URL + +// This doesn't belong here, but i'm tired. +fun Long.getProfileUrl(): String = "${Constants.Persona.PROFILE_URL}$this/" From 4f5de2429adb1425fe778e98b25f59354a3858c2 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 21 Jan 2025 16:56:45 -0600 Subject: [PATCH 04/49] Update dependencies, and updated color scheme of setting items. --- .../ui/component/dialog/Box64PresetsDialog.kt | 6 ++++ .../component/dialog/ContainerConfigDialog.kt | 29 ++++++++++++++++++- .../ui/component/settings/SettingsCPUList.kt | 5 ++-- .../settings/SettingsCenteredLabel.kt | 5 ++-- .../ui/component/settings/SettingsEnvVars.kt | 7 +++++ .../settings/SettingsListDropdown.kt | 5 ++-- .../settings/SettingsMultiListDropdown.kt | 5 ++-- .../settings/SettingsSwitchWithAction.kt | 5 ++-- .../component/settings/SettingsTextField.kt | 5 ++-- gradle/libs.versions.toml | 22 +++++++------- 10 files changed, 70 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt index d6627b6e..6570a53f 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.OxGames.Pluvia.ui.component.settings.SettingsEnvVars +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.winlator.box86_64.Box86_64Preset import com.winlator.box86_64.Box86_64PresetManager import com.winlator.core.StringUtils @@ -90,6 +91,10 @@ fun Box64PresetsDialog( } val isCustom: () -> Boolean = { getPreset(presetId).isCustom } + val settingsColor = SettingsTileDefaults.colors( + actionColor = MaterialTheme.colorScheme.onSurface, + ) + Column( modifier = Modifier .fillMaxSize() @@ -203,6 +208,7 @@ fun Box64PresetsDialog( useHtmlInMsg = true, ) SettingsEnvVars( + colors = settingsColor, enabled = isCustom(), envVars = EnvVars(envVars), onEnvVarsChange = { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt index eb71cd23..8b37a3ba 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -63,6 +64,7 @@ import com.OxGames.Pluvia.utils.ContainerUtils import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink import com.alorma.compose.settings.ui.SettingsSwitch +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.winlator.box86_64.Box86_64PresetManager import com.winlator.container.Container import com.winlator.container.ContainerData @@ -311,8 +313,15 @@ fun ContainerConfigDialog( ) .fillMaxSize(), ) { - SettingsGroup(title = { Text(text = "General") }) { + val settingsColor = SettingsTileDefaults.colors( + actionColor = MaterialTheme.colorScheme.onSurface, + ) + + SettingsGroup( + title = { Text(text = "General") }, + ) { SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Screen Size") }, value = screenSizeIndex, items = screenSizes, @@ -358,6 +367,7 @@ fun ContainerConfigDialog( ) // TODO: add way to pick driver version SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Graphics Driver") }, value = graphicsDriverIndex, items = graphicsDrivers, @@ -368,6 +378,7 @@ fun ContainerConfigDialog( ) // TODO: add way to pick DXVK version SettingsListDropdown( + colors = settingsColor, title = { Text(text = "DX Wrapper") }, value = dxWrapperIndex, items = dxWrappers, @@ -388,6 +399,7 @@ fun ContainerConfigDialog( ) // TODO: add way to configure audio driver SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Audio Driver") }, value = audioDriverIndex, items = audioDrivers, @@ -397,6 +409,7 @@ fun ContainerConfigDialog( }, ) SettingsSwitch( + colors = settingsColor, title = { Text(text = "Show FPS") }, state = config.showFPS, onCheckedChange = { @@ -407,6 +420,7 @@ fun ContainerConfigDialog( SettingsGroup(title = { Text(text = "Wine Configuration") }) { // TODO: add desktop settings SettingsListDropdown( + colors = settingsColor, title = { Text(text = "GPU Name") }, subtitle = { Text(text = "WineD3D") }, value = gpuNameIndex, @@ -417,6 +431,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Offscreen Rendering Mode") }, subtitle = { Text(text = "WineD3D") }, value = renderingModeIndex, @@ -427,6 +442,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Video Memory Size") }, subtitle = { Text(text = "WineD3D") }, value = videoMemIndex, @@ -453,6 +469,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Mouse Warp Override") }, subtitle = { Text(text = "DirectInput") }, value = mouseWarpIndex, @@ -469,6 +486,7 @@ fun ContainerConfigDialog( val compName = winComponentsItemTitle(compId) val compValue = wincomponent[1].toInt() SettingsListDropdown( + colors = settingsColor, title = { Text(compName) }, subtitle = { Text(if (compId.startsWith("direct")) "DirectX" else "General") }, value = compValue, @@ -485,6 +503,7 @@ fun ContainerConfigDialog( val envVars = EnvVars(config.envVars) if (config.envVars.isNotEmpty()) { SettingsEnvVars( + colors = settingsColor, envVars = envVars, onEnvVarsChange = { config = config.copy(envVars = it.toString()) @@ -506,6 +525,7 @@ fun ContainerConfigDialog( ) } else { SettingsCenteredLabel( + colors = SettingsTileDefaults.colors(titleColor = MaterialTheme.colorScheme.onSurface), title = { Text(text = "No environment variables") }, ) } @@ -543,6 +563,7 @@ fun ContainerConfigDialog( val driveLetter = drive[0] val drivePath = drive[1] SettingsMenuLink( + colors = settingsColor, title = { Text(driveLetter) }, subtitle = { Text(drivePath) }, onClick = {}, @@ -560,6 +581,7 @@ fun ContainerConfigDialog( } } else { SettingsCenteredLabel( + colors = SettingsTileDefaults.colors(titleColor = MaterialTheme.colorScheme.onSurface), title = { Text(text = "No drives") }, ) } @@ -585,6 +607,7 @@ fun ContainerConfigDialog( } SettingsGroup(title = { Text(text = "Advanced") }) { SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Box64 Version") }, subtitle = { Text(text = "Box64") }, value = box64Versions.indexOfFirst { StringUtils.parseIdentifier(it) == config.box64Version }, @@ -596,6 +619,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Box64 Preset") }, subtitle = { Text(text = "Box64") }, value = box64Presets.indexOfFirst { it.id == config.box64Preset }, @@ -607,6 +631,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( + colors = settingsColor, title = { Text(text = "Startup Selection") }, subtitle = { Text(text = "System") }, value = config.startupSelection.toInt(), @@ -618,6 +643,7 @@ fun ContainerConfigDialog( }, ) SettingsCPUList( + colors = settingsColor, title = { Text(text = "Processor Affinity") }, value = config.cpuList, onValueChange = { @@ -627,6 +653,7 @@ fun ContainerConfigDialog( }, ) SettingsCPUList( + colors = settingsColor, title = { Text(text = "Processor Affinity (32-bit apps)") }, value = config.cpuListWoW64, onValueChange = { config = config.copy(cpuListWoW64 = it) }, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCPUList.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCPUList.kt index a6f0be84..7859c64e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCPUList.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCPUList.kt @@ -5,13 +5,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Checkbox -import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.alorma.compose.settings.ui.base.internal.SettingsTileScaffold @Composable @@ -22,7 +23,7 @@ fun SettingsCPUList( onValueChange: (String) -> Unit, title: @Composable () -> Unit, icon: (@Composable () -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + colors: SettingsTileColors = SettingsTileDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, action: @Composable (() -> Unit)? = null, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCenteredLabel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCenteredLabel.kt index 9dffd49c..6be150fc 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCenteredLabel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsCenteredLabel.kt @@ -4,7 +4,6 @@ import android.R.attr.enabled import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -15,6 +14,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.alorma.compose.settings.ui.base.internal.SettingsTileScaffold @Composable @@ -24,7 +25,7 @@ fun SettingsCenteredLabel( subtitle: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null, action: @Composable (() -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + colors: SettingsTileColors = SettingsTileDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, ) { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsEnvVars.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsEnvVars.kt index 858ce09c..30ce19c6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsEnvVars.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsEnvVars.kt @@ -3,6 +3,8 @@ package com.OxGames.Pluvia.ui.component.settings import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.winlator.core.envvars.EnvVarInfo import com.winlator.core.envvars.EnvVarSelectionType import com.winlator.core.envvars.EnvVars @@ -12,6 +14,7 @@ import kotlin.text.split fun SettingsEnvVars( enabled: Boolean = LocalSettingsGroupEnabled.current, envVars: EnvVars, + colors: SettingsTileColors = SettingsTileDefaults.colors(), onEnvVarsChange: (EnvVars) -> Unit, knownEnvVars: Map, envVarAction: (@Composable (String) -> Unit)? = null, @@ -22,6 +25,7 @@ fun SettingsEnvVars( when (envVarInfo?.selectionType ?: EnvVarSelectionType.NONE) { EnvVarSelectionType.TOGGLE -> { SettingsSwitchWithAction( + colors = colors, enabled = enabled, title = { Text(identifier) }, state = envVarInfo?.possibleValues?.indexOf(value) != 0, @@ -40,6 +44,7 @@ fun SettingsEnvVars( .map { envVarInfo!!.possibleValues.indexOf(it) } .filter { it >= 0 && it < envVarInfo!!.possibleValues.size } SettingsMultiListDropdown( + colors = colors, enabled = enabled, title = { Text(identifier) }, values = values, @@ -65,6 +70,7 @@ fun SettingsEnvVars( EnvVarSelectionType.NONE -> { if (envVarInfo?.possibleValues?.isNotEmpty() == true) { SettingsListDropdown( + colors = colors, enabled = enabled, title = { Text(identifier) }, value = envVarInfo.possibleValues.indexOf(value), @@ -80,6 +86,7 @@ fun SettingsEnvVars( ) } else { SettingsTextField( + colors = colors, enabled = enabled, title = { Text(identifier) }, value = value, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsListDropdown.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsListDropdown.kt index 546cb997..2fcafc7a 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsListDropdown.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsListDropdown.kt @@ -11,7 +11,6 @@ import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +27,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.alorma.compose.settings.ui.base.internal.SettingsTileScaffold @Composable @@ -41,7 +42,7 @@ fun SettingsListDropdown( title: @Composable () -> Unit, subtitle: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + colors: SettingsTileColors = SettingsTileDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, action: @Composable (() -> Unit)? = null, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsMultiListDropdown.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsMultiListDropdown.kt index 55f74e0d..71ef7e6c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsMultiListDropdown.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsMultiListDropdown.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,6 +29,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.alorma.compose.settings.ui.base.internal.SettingsTileScaffold @Composable @@ -43,7 +44,7 @@ fun SettingsMultiListDropdown( title: @Composable () -> Unit, subtitle: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + colors: SettingsTileColors = SettingsTileDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, action: @Composable (() -> Unit)? = null, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsSwitchWithAction.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsSwitchWithAction.kt index c8f6f3bb..b4a9e5aa 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsSwitchWithAction.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsSwitchWithAction.kt @@ -1,7 +1,6 @@ package com.OxGames.Pluvia.ui.component.settings import androidx.compose.foundation.layout.Row -import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Switch import androidx.compose.runtime.Composable @@ -9,6 +8,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.alorma.compose.settings.ui.SettingsMenuLink import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults @Composable fun SettingsSwitchWithAction( @@ -19,7 +20,7 @@ fun SettingsSwitchWithAction( icon: @Composable (() -> Unit)? = null, subtitle: @Composable (() -> Unit)? = null, action: @Composable (() -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + colors: SettingsTileColors = SettingsTileDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, onCheckedChange: (Boolean) -> Unit, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsTextField.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsTextField.kt index 8eff4631..0de40ca6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsTextField.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/settings/SettingsTextField.kt @@ -2,7 +2,6 @@ package com.OxGames.Pluvia.ui.component.settings import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.width -import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable @@ -14,6 +13,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.alorma.compose.settings.ui.SettingsMenuLink import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults @Composable fun SettingsTextField( @@ -24,7 +25,7 @@ fun SettingsTextField( icon: @Composable (() -> Unit)? = null, subtitle: @Composable (() -> Unit)? = null, action: @Composable (() -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + colors: SettingsTileColors = SettingsTileDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, onValueChange: (String) -> Unit, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97a45d9..0996f315 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,34 +1,34 @@ [versions] -activityCompose = "1.9.3" # https://mvnrepository.com/artifact/androidx.activity/activity-compose +activityCompose = "1.10.0" # https://mvnrepository.com/artifact/androidx.activity/activity-compose agp = "8.7.3" # https://mvnrepository.com/artifact/com.android.application/com.android.application.gradle.plugin apache-compress = "1.27.1" # https://mvnrepository.com/artifact/org.apache.commons/commons-compress -composeBom = "2024.12.01" # https://mvnrepository.com/artifact/androidx.compose/compose-bom +composeBom = "2025.01.00" # https://mvnrepository.com/artifact/androidx.compose/compose-bom coreKtx = "1.15.0" # https://mvnrepository.com/artifact/androidx.core/core-ktx -coroutines = "1.10.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core -dagger-hilt = "2.53.1" # https://mvnrepository.com/artifact/com.google.dagger/hilt-android -dataStore = "1.1.1" # https://mvnrepository.com/artifact/androidx.datastore/datastore-preferences +coroutines = "1.10.1" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core +dagger-hilt = "2.55" # https://mvnrepository.com/artifact/com.google.dagger/hilt-android +dataStore = "1.1.2" # https://mvnrepository.com/artifact/androidx.datastore/datastore-preferences espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espresso/espresso-core feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -json = "1.7.3" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json +json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit kotlin = "2.0.21" # https://mvnrepository.com/artifact/org.jetbrains.kotlin.android/org.jetbrains.kotlin.android.gradle.plugin kotlinter = "5.0.1" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter ksp = "2.0.21-1.0.28" # https://mvnrepository.com/artifact/com.google.devtools.ksp/symbol-processing-api -landscapistCoil = "2.4.4" # https://mvnrepository.com/artifact/com.github.skydoves/landscapist-coil +landscapistCoil = "2.4.6" # https://mvnrepository.com/artifact/com.github.skydoves/landscapist-coil lifecycleRuntimeKtx = "2.8.7" # https://mvnrepository.com/artifact/androidx.lifecycle/lifecycle-runtime-ktx material3Adaptive = "1.0.0" # https://mvnrepository.com/artifact/androidx.compose.material3.adaptive/adaptive-layout material3AdaptiveNavSuite = "1.3.1" # https://mvnrepository.com/artifact/androidx.compose.material3/material3-adaptive-navigation-suite materialKolor = "2.0.0" # https://mvnrepository.com/artifact/com.materialkolor/material-kolor-android navigation-compose = "2.8.5" # https://mvnrepository.com/artifact/androidx.navigation/navigation-compose -protobuf = "4.29.2" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java +protobuf = "4.29.3" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java room-runtime = "2.6.1" # https://mvnrepository.com/artifact/androidx.room/room-runtime -settings = "2.9.0" # https://github.com/alorma/Compose-Settings/releases +settings = "2.10.0" # https://github.com/alorma/Compose-Settings/releases spongycastle = "1.58.0.0" # https://mvnrepository.com/artifact/com.madgag.spongycastle/prov steamkit = "1.6.0-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam timber = "5.0.1" # https://mvnrepository.com/artifact/com.jakewharton.timber/timber -zstd-jni = "1.5.2-3" # https://mvnrepository.com/artifact/com.github.luben/zstd-jni +zstd-jni = "1.5.6-9" # https://mvnrepository.com/artifact/com.github.luben/zstd-jni zxing = "3.5.3" # https://mvnrepository.com/artifact/com.google.zxing/core [libraries] @@ -79,7 +79,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man junit = { group = "junit", name = "junit", version.ref = "junit" } # Dependencies when locally building JavaSteam -commons-io = { module = "commons-io:commons-io", version = "2.17.0" } # https://mvnrepository.com/artifact/commons-io/commons-io +commons-io = { module = "commons-io:commons-io", version = "2.18.0" } # https://mvnrepository.com/artifact/commons-io/commons-io commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.17.0" } # https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 commons-validator = { module = "commons-validator:commons-validator", version = "1.9.0" } # https://mvnrepository.com/artifact/commons-validator/commons-validator ktor-engine = { module = "io.ktor:ktor-client-cio", version = "3.0.3" } # https://mvnrepository.com/artifact/io.ktor/ktor-client-cio From 68fe4788aa32604b1ecf839afa451f04cbae038a Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 21 Jan 2025 22:11:27 -0600 Subject: [PATCH 05/49] Restore settings theme to original design. Reorganized Info group, putting links at the bottom. --- .../ui/component/dialog/Box64PresetsDialog.kt | 8 +--- .../component/dialog/ContainerConfigDialog.kt | 48 +++++++++---------- .../ui/screen/settings/SettingsGroupDebug.kt | 4 ++ .../screen/settings/SettingsGroupEmulation.kt | 4 ++ .../ui/screen/settings/SettingsGroupInfo.kt | 31 +++++++----- .../java/com/OxGames/Pluvia/ui/theme/Color.kt | 20 ++++++++ 6 files changed, 72 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt index 6570a53f..91ad636b 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/Box64PresetsDialog.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.OxGames.Pluvia.ui.component.settings.SettingsEnvVars -import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults +import com.OxGames.Pluvia.ui.theme.settingsTileColors import com.winlator.box86_64.Box86_64Preset import com.winlator.box86_64.Box86_64PresetManager import com.winlator.core.StringUtils @@ -91,10 +91,6 @@ fun Box64PresetsDialog( } val isCustom: () -> Boolean = { getPreset(presetId).isCustom } - val settingsColor = SettingsTileDefaults.colors( - actionColor = MaterialTheme.colorScheme.onSurface, - ) - Column( modifier = Modifier .fillMaxSize() @@ -208,7 +204,7 @@ fun Box64PresetsDialog( useHtmlInMsg = true, ) SettingsEnvVars( - colors = settingsColor, + colors = settingsTileColors(), enabled = isCustom(), envVars = EnvVars(envVars), onEnvVarsChange = { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt index 8b37a3ba..27ac003e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/ContainerConfigDialog.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -60,11 +59,12 @@ import com.OxGames.Pluvia.ui.component.settings.SettingsCPUList import com.OxGames.Pluvia.ui.component.settings.SettingsCenteredLabel import com.OxGames.Pluvia.ui.component.settings.SettingsEnvVars import com.OxGames.Pluvia.ui.component.settings.SettingsListDropdown +import com.OxGames.Pluvia.ui.theme.settingsTileColors +import com.OxGames.Pluvia.ui.theme.settingsTileColorsAlt import com.OxGames.Pluvia.utils.ContainerUtils import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink import com.alorma.compose.settings.ui.SettingsSwitch -import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.winlator.box86_64.Box86_64PresetManager import com.winlator.container.Container import com.winlator.container.ContainerData @@ -313,15 +313,11 @@ fun ContainerConfigDialog( ) .fillMaxSize(), ) { - val settingsColor = SettingsTileDefaults.colors( - actionColor = MaterialTheme.colorScheme.onSurface, - ) - SettingsGroup( title = { Text(text = "General") }, ) { SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Screen Size") }, value = screenSizeIndex, items = screenSizes, @@ -367,7 +363,7 @@ fun ContainerConfigDialog( ) // TODO: add way to pick driver version SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Graphics Driver") }, value = graphicsDriverIndex, items = graphicsDrivers, @@ -378,7 +374,7 @@ fun ContainerConfigDialog( ) // TODO: add way to pick DXVK version SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "DX Wrapper") }, value = dxWrapperIndex, items = dxWrappers, @@ -399,7 +395,7 @@ fun ContainerConfigDialog( ) // TODO: add way to configure audio driver SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Audio Driver") }, value = audioDriverIndex, items = audioDrivers, @@ -409,7 +405,7 @@ fun ContainerConfigDialog( }, ) SettingsSwitch( - colors = settingsColor, + colors = settingsTileColorsAlt(), title = { Text(text = "Show FPS") }, state = config.showFPS, onCheckedChange = { @@ -420,7 +416,7 @@ fun ContainerConfigDialog( SettingsGroup(title = { Text(text = "Wine Configuration") }) { // TODO: add desktop settings SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "GPU Name") }, subtitle = { Text(text = "WineD3D") }, value = gpuNameIndex, @@ -431,7 +427,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Offscreen Rendering Mode") }, subtitle = { Text(text = "WineD3D") }, value = renderingModeIndex, @@ -442,7 +438,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Video Memory Size") }, subtitle = { Text(text = "WineD3D") }, value = videoMemIndex, @@ -453,6 +449,7 @@ fun ContainerConfigDialog( }, ) SettingsSwitch( + colors = settingsTileColorsAlt(), title = { Text(text = "Enable CSMT (Command Stream Multi-Thread)") }, subtitle = { Text(text = "WineD3D") }, state = config.csmt, @@ -461,6 +458,7 @@ fun ContainerConfigDialog( }, ) SettingsSwitch( + colors = settingsTileColorsAlt(), title = { Text(text = "Enable Strict Shader Math") }, subtitle = { Text(text = "WineD3D") }, state = config.strictShaderMath, @@ -469,7 +467,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Mouse Warp Override") }, subtitle = { Text(text = "DirectInput") }, value = mouseWarpIndex, @@ -486,7 +484,7 @@ fun ContainerConfigDialog( val compName = winComponentsItemTitle(compId) val compValue = wincomponent[1].toInt() SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(compName) }, subtitle = { Text(if (compId.startsWith("direct")) "DirectX" else "General") }, value = compValue, @@ -503,7 +501,7 @@ fun ContainerConfigDialog( val envVars = EnvVars(config.envVars) if (config.envVars.isNotEmpty()) { SettingsEnvVars( - colors = settingsColor, + colors = settingsTileColors(), envVars = envVars, onEnvVarsChange = { config = config.copy(envVars = it.toString()) @@ -525,7 +523,7 @@ fun ContainerConfigDialog( ) } else { SettingsCenteredLabel( - colors = SettingsTileDefaults.colors(titleColor = MaterialTheme.colorScheme.onSurface), + colors = settingsTileColors(), title = { Text(text = "No environment variables") }, ) } @@ -563,7 +561,7 @@ fun ContainerConfigDialog( val driveLetter = drive[0] val drivePath = drive[1] SettingsMenuLink( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(driveLetter) }, subtitle = { Text(drivePath) }, onClick = {}, @@ -581,7 +579,7 @@ fun ContainerConfigDialog( } } else { SettingsCenteredLabel( - colors = SettingsTileDefaults.colors(titleColor = MaterialTheme.colorScheme.onSurface), + colors = settingsTileColors(), title = { Text(text = "No drives") }, ) } @@ -607,7 +605,7 @@ fun ContainerConfigDialog( } SettingsGroup(title = { Text(text = "Advanced") }) { SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Box64 Version") }, subtitle = { Text(text = "Box64") }, value = box64Versions.indexOfFirst { StringUtils.parseIdentifier(it) == config.box64Version }, @@ -619,7 +617,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Box64 Preset") }, subtitle = { Text(text = "Box64") }, value = box64Presets.indexOfFirst { it.id == config.box64Preset }, @@ -631,7 +629,7 @@ fun ContainerConfigDialog( }, ) SettingsListDropdown( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Startup Selection") }, subtitle = { Text(text = "System") }, value = config.startupSelection.toInt(), @@ -643,7 +641,7 @@ fun ContainerConfigDialog( }, ) SettingsCPUList( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Processor Affinity") }, value = config.cpuList, onValueChange = { @@ -653,7 +651,7 @@ fun ContainerConfigDialog( }, ) SettingsCPUList( - colors = settingsColor, + colors = settingsTileColors(), title = { Text(text = "Processor Affinity (32-bit apps)") }, value = config.cpuListWoW64, onValueChange = { config = config.copy(cpuListWoW64 = it) }, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupDebug.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupDebug.kt index 18e4a034..66ea97b7 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupDebug.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupDebug.kt @@ -18,6 +18,7 @@ import coil.imageLoader import com.OxGames.Pluvia.BuildConfig import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.ui.component.dialog.CrashLogDialog +import com.OxGames.Pluvia.ui.theme.settingsTileColors import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink import java.io.File @@ -66,6 +67,7 @@ fun SettingsGroupDebug() { SettingsGroup(title = { Text(text = "Debug") }) { SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "View latest crash") }, subtitle = { val text = if (latestCrashFile != null) { @@ -81,6 +83,7 @@ fun SettingsGroupDebug() { if (BuildConfig.DEBUG) { SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "Clear Preferences") }, onClick = { scope.launch { @@ -91,6 +94,7 @@ fun SettingsGroupDebug() { ) SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "Clear Image Cache") }, onClick = { context.imageLoader.diskCache?.clear() diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupEmulation.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupEmulation.kt index 7c3d4a4c..92782a78 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupEmulation.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupEmulation.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import com.OxGames.Pluvia.ui.component.dialog.Box64PresetsDialog import com.OxGames.Pluvia.ui.component.dialog.ContainerConfigDialog import com.OxGames.Pluvia.ui.component.dialog.OrientationDialog +import com.OxGames.Pluvia.ui.theme.settingsTileColors import com.OxGames.Pluvia.utils.ContainerUtils import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink @@ -42,16 +43,19 @@ fun SettingsGroupEmulation() { ) SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "Allowed Orientations") }, subtitle = { Text(text = "Choose which orientations can be rotated to when in-game") }, onClick = { showOrientationDialog = true }, ) SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "Modify Default Config") }, subtitle = { Text(text = "The initial container settings for each game (does not affect already installed games)") }, onClick = { showConfigDialog = true }, ) SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "Box64 Presets") }, subtitle = { Text("View, modify, and create Box64 presets") }, onClick = { showBox64PresetsDialog = true }, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInfo.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInfo.kt index a2e8d3d2..578be13f 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInfo.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInfo.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.platform.LocalUriHandler import com.OxGames.Pluvia.Constants import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.ui.component.dialog.LibrariesDialog +import com.OxGames.Pluvia.ui.theme.settingsTileColors +import com.OxGames.Pluvia.ui.theme.settingsTileColorsAlt import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink import com.alorma.compose.settings.ui.SettingsSwitch @@ -30,18 +32,7 @@ fun SettingsGroupInfo() { ) SettingsMenuLink( - title = { Text(text = "Source code") }, - subtitle = { Text(text = "View the source code of this project") }, - onClick = { uriHandler.openUri(Constants.Misc.GITHUB_LINK) }, - ) - - SettingsMenuLink( - title = { Text(text = "Libraries Used") }, - subtitle = { Text(text = "See what technologies make Pluvia possible") }, - onClick = { showLibrariesDialog = true }, - ) - - SettingsMenuLink( + colors = settingsTileColors(), title = { Text("Send tip") }, subtitle = { Text(text = "Contribute to ongoing development") }, icon = { Icon(imageVector = Icons.Filled.MonetizationOn, contentDescription = "Tip") }, @@ -53,6 +44,7 @@ fun SettingsGroupInfo() { ) SettingsSwitch( + colors = settingsTileColorsAlt(), state = askForTip, title = { Text("Ask for tip on startup") }, subtitle = { Text(text = "Stops the tip message from appearing") }, @@ -63,6 +55,21 @@ fun SettingsGroupInfo() { ) SettingsMenuLink( + colors = settingsTileColors(), + title = { Text(text = "Source code") }, + subtitle = { Text(text = "View the source code of this project") }, + onClick = { uriHandler.openUri(Constants.Misc.GITHUB_LINK) }, + ) + + SettingsMenuLink( + colors = settingsTileColors(), + title = { Text(text = "Libraries Used") }, + subtitle = { Text(text = "See what technologies make Pluvia possible") }, + onClick = { showLibrariesDialog = true }, + ) + + SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "Privacy Policy") }, subtitle = { Text(text = "Opens a link to Pluvia's privacy policy") }, onClick = { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt b/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt index c654ea91..c980e77d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt @@ -1,6 +1,10 @@ package com.OxGames.Pluvia.ui.theme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults val pluviaSeedColor = Color(0x284561FF) @@ -10,3 +14,19 @@ val friendInGame = Color(0xFF90BA3C) val friendInGameAwayOrSnooze = Color(0x8090BA3C) val friendOffline = Color(0xFF7A7A7A) val friendOnline = Color(0xFF6DCFF6) + +/** + * Alorma compose settings tile colors + */ +@Composable +fun settingsTileColors(): SettingsTileColors = SettingsTileDefaults.colors( + titleColor = MaterialTheme.colorScheme.onSurface, + subtitleColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .75f), + actionColor = MaterialTheme.colorScheme.onSurface, +) + +@Composable +fun settingsTileColorsAlt(): SettingsTileColors = SettingsTileDefaults.colors( + titleColor = MaterialTheme.colorScheme.onSurface, + subtitleColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .75f), +) From e8e620c4a28735693d82dca7d9989a83dac5793f Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 21 Jan 2025 23:56:15 -0600 Subject: [PATCH 06/49] Work on chat text input --- .../Pluvia/ui/screen/friends/ChatInput.kt | 579 ++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatInput.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatInput.kt new file mode 100644 index 00000000..696b61e1 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatInput.kt @@ -0,0 +1,579 @@ +package com.OxGames.Pluvia.ui.screen.friends + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.EmojiEmotions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.OxGames.Pluvia.ui.theme.PluviaTheme + +/** + * Heavily referenced from: + * https://github.com/android/compose-samples/tree/main/Jetchat + */ + +val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") +var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey + +private const val EMOJI_COLUMNS = 10 + +enum class EmojiStickerSelector { + EMOJI, + STICKER, +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatInput( + onMessageSent: (String) -> Unit, + modifier: Modifier = Modifier, + resetScroll: () -> Unit = {}, +) { + var isEmoticonsShowing by rememberSaveable { mutableStateOf(false) } + val dismissKeyboard = { isEmoticonsShowing = false } + + // Intercept back navigation if there's a InputSelector visible + if (!isEmoticonsShowing) { + BackHandler(onBack = dismissKeyboard) + } + + var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + // Used to decide if the keyboard should be shown + var textFieldFocusState by remember { mutableStateOf(false) } + + Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) { + Column(modifier = modifier) { + UserInputText( + textFieldValue = textState, + onTextChanged = { textState = it }, + // Only show the keyboard if there's no input selector and text field has focus + keyboardShown = !isEmoticonsShowing && textFieldFocusState, + // Close extended selector if text field receives focus + onTextFieldFocused = { focused -> + if (focused) { + isEmoticonsShowing = false + resetScroll() + } + textFieldFocusState = focused + }, + onMessageSent = { + onMessageSent(textState.text) + // Reset text field and close keyboard + textState = TextFieldValue() + // Move scroll to bottom + resetScroll() + }, + isEmoticonShowing = isEmoticonsShowing, + onEmoticonClick = { + isEmoticonsShowing = !isEmoticonsShowing + }, + ) + + SelectorExpanded( + isEmoticonsShowing = isEmoticonsShowing, + onCloseRequested = dismissKeyboard, + onTextAdded = { textState = textState.addText(it) }, + ) + } + } +} + +private fun TextFieldValue.addText(newString: String): TextFieldValue { + val newText = this.text.replaceRange( + this.selection.start, + this.selection.end, + newString, + ) + val newSelection = TextRange( + start = newText.length, + end = newText.length, + ) + + return this.copy(text = newText, selection = newSelection) +} + +@Composable +private fun SelectorExpanded( + isEmoticonsShowing: Boolean, + onCloseRequested: () -> Unit, + onTextAdded: (String) -> Unit, +) { + if (!isEmoticonsShowing) return + + // Request focus to force the TextField to lose it + val focusRequester = FocusRequester() + // If the selector is shown, always request focus to trigger a TextField.onFocusChange. + SideEffect { + if (isEmoticonsShowing) { + focusRequester.requestFocus() + } + } + + var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) } + + Surface(tonalElevation = 8.dp) { + if (isEmoticonsShowing) { + EmojiSelector( + focusRequester = focusRequester, + emojiSelector = selected, + onInnerSelection = { selected = it }, + onTextAdded = onTextAdded, + ) + } + } +} + +@ExperimentalFoundationApi +@Composable +private fun UserInputText( + keyboardType: KeyboardType = KeyboardType.Text, + onTextChanged: (TextFieldValue) -> Unit, + textFieldValue: TextFieldValue, + keyboardShown: Boolean, + onTextFieldFocused: (Boolean) -> Unit, + onMessageSent: () -> Unit, + isEmoticonShowing: Boolean, + onEmoticonClick: () -> Unit, +) { + Box( + Modifier + .height(56.dp) + .fillMaxSize(), + ) { + UserInputTextField( + modifier = Modifier + .fillMaxSize() + .semantics { + keyboardShownProperty = keyboardShown + }, + textFieldValue = textFieldValue, + onTextChanged = onTextChanged, + onTextFieldFocused = onTextFieldFocused, + keyboardType = keyboardType, + onMessageSent = onMessageSent, + isEmoticonShowing = isEmoticonShowing, + onEmoticonClick = onEmoticonClick, + ) + } +} + +@Composable +private fun UserInputTextField( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit, + onTextFieldFocused: (Boolean) -> Unit, + isEmoticonShowing: Boolean, + onEmoticonClick: () -> Unit, + keyboardType: KeyboardType, + onMessageSent: () -> Unit, +) { + var lastFocusState by remember { mutableStateOf(false) } + + TextField( + modifier = modifier + .onFocusChanged { state -> + if (lastFocusState != state.isFocused) { + onTextFieldFocused(state.isFocused) + } + lastFocusState = state.isFocused + }, + value = textFieldValue, + onValueChange = { onTextChanged(it) }, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions { + if (textFieldValue.text.isNotBlank()) onMessageSent() + }, + maxLines = 1, + textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), + placeholder = { + Text(text = "Send a message") + }, + leadingIcon = { + val colors = if (!isEmoticonShowing) { + IconButtonDefaults.iconButtonColors() + } else { + IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.onPrimary) + } + + IconButton( + colors = colors, + onClick = onEmoticonClick, + content = { + Icon(imageVector = Icons.Outlined.EmojiEmotions, null) + }, + ) + }, + trailingIcon = { + val buttonColors = ButtonDefaults.buttonColors( + disabledContainerColor = Color.Transparent, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + ) + val border = if (textFieldValue.text.trim().isEmpty()) { + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + ) + } else { + null + } + Button( + modifier = Modifier + .padding(horizontal = 12.dp) + .height(36.dp), + enabled = textFieldValue.text.trim().isNotEmpty(), + onClick = onMessageSent, + colors = buttonColors, + border = border, + contentPadding = PaddingValues(0.dp), + content = { + Text( + text = "Send", + modifier = Modifier.padding(horizontal = 16.dp), + ) + }, + ) + }, + ) +} + +@Composable +fun EmojiSelector( + focusRequester: FocusRequester, + emojiSelector: EmojiStickerSelector, + onInnerSelection: (EmojiStickerSelector) -> Unit, + onTextAdded: (String) -> Unit, +) { + Column( + modifier = Modifier + .focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed + .focusTarget(), // Make the emoji selector focusable so it can steal focus from TextField + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + ExtendedSelectorInnerButton( + text = "Emoticons", + onClick = { onInnerSelection(EmojiStickerSelector.EMOJI) }, + selected = emojiSelector == EmojiStickerSelector.EMOJI, + modifier = Modifier.weight(1f), + ) + ExtendedSelectorInnerButton( + text = "Stickers", + onClick = { onInnerSelection(EmojiStickerSelector.STICKER) }, + selected = emojiSelector == EmojiStickerSelector.STICKER, + modifier = Modifier.weight(1f), + ) + } + + when (emojiSelector) { + EmojiStickerSelector.EMOJI -> { + Row(modifier = Modifier.verticalScroll(rememberScrollState())) { + EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp)) + } + } + + EmojiStickerSelector.STICKER -> { + Text("TODO: Stickers") + } + } + } +} + +@Composable +fun ExtendedSelectorInnerButton( + text: String, + onClick: () -> Unit, + selected: Boolean, + modifier: Modifier = Modifier, +) { + val colors = ButtonDefaults.buttonColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + } else { + Color.Transparent + }, + disabledContainerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f), + ) + TextButton( + modifier = modifier + .padding(8.dp) + .height(36.dp), + onClick = onClick, + colors = colors, + contentPadding = PaddingValues(0.dp), + content = { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + ) + }, + ) +} + +@Composable +fun EmojiTable( + onTextAdded: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxWidth()) { + repeat(4) { x -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + repeat(EMOJI_COLUMNS) { y -> + val emoji = emojis[x * EMOJI_COLUMNS + y] + Text( + modifier = Modifier + .clickable(onClick = { onTextAdded(emoji) }) + .sizeIn(minWidth = 42.dp, minHeight = 42.dp) + .padding(8.dp), + text = emoji, + style = LocalTextStyle.current.copy( + fontSize = 18.sp, + textAlign = TextAlign.Center, + ), + ) + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +fun Preview_ChatInput() { + PluviaTheme { + Column( + modifier = Modifier + .imePadding() + .fillMaxSize(), + ) { + Box(modifier = Modifier.weight(1f)) + ChatInput(onMessageSent = {}) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +fun Preview_Emoticons() { + val focusRequester = FocusRequester() + PluviaTheme { + EmojiSelector( + focusRequester = focusRequester, + emojiSelector = EmojiStickerSelector.EMOJI, + onInnerSelection = {}, + onTextAdded = {}, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +fun Preview_Stickers() { + val focusRequester = FocusRequester() + PluviaTheme { + EmojiSelector( + focusRequester = focusRequester, + emojiSelector = EmojiStickerSelector.STICKER, + onInnerSelection = {}, + onTextAdded = {}, + ) + } +} + +private val emojis = listOf( + "\ud83d\ude00", // Grinning Face + "\ud83d\ude01", // Grinning Face With Smiling Eyes + "\ud83d\ude02", // Face With Tears of Joy + "\ud83d\ude03", // Smiling Face With Open Mouth + "\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes + "\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat + "\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes + "\ud83d\ude09", // Winking Face + "\ud83d\ude0a", // Smiling Face With Smiling Eyes + "\ud83d\ude0b", // Face Savouring Delicious Food + "\ud83d\ude0e", // Smiling Face With Sunglasses + "\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes + "\ud83d\ude18", // Face Throwing a Kiss + "\ud83d\ude17", // Kissing Face + "\ud83d\ude19", // Kissing Face With Smiling Eyes + "\ud83d\ude1a", // Kissing Face With Closed Eyes + "\u263a", // White Smiling Face + "\ud83d\ude42", // Slightly Smiling Face + "\ud83e\udd17", // Hugging Face + "\ud83d\ude07", // Smiling Face With Halo + "\ud83e\udd13", // Nerd Face + "\ud83e\udd14", // Thinking Face + "\ud83d\ude10", // Neutral Face + "\ud83d\ude11", // Expressionless Face + "\ud83d\ude36", // Face Without Mouth + "\ud83d\ude44", // Face With Rolling Eyes + "\ud83d\ude0f", // Smirking Face + "\ud83d\ude23", // Persevering Face + "\ud83d\ude25", // Disappointed but Relieved Face + "\ud83d\ude2e", // Face With Open Mouth + "\ud83e\udd10", // Zipper-Mouth Face + "\ud83d\ude2f", // Hushed Face + "\ud83d\ude2a", // Sleepy Face + "\ud83d\ude2b", // Tired Face + "\ud83d\ude34", // Sleeping Face + "\ud83d\ude0c", // Relieved Face + "\ud83d\ude1b", // Face With Stuck-Out Tongue + "\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye + "\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes + "\ud83d\ude12", // Unamused Face + "\ud83d\ude13", // Face With Cold Sweat + "\ud83d\ude14", // Pensive Face + "\ud83d\ude15", // Confused Face + "\ud83d\ude43", // Upside-Down Face + "\ud83e\udd11", // Money-Mouth Face + "\ud83d\ude32", // Astonished Face + "\ud83d\ude37", // Face With Medical Mask + "\ud83e\udd12", // Face With Thermometer + "\ud83e\udd15", // Face With Head-Bandage + "\u2639", // White Frowning Face + "\ud83d\ude41", // Slightly Frowning Face + "\ud83d\ude16", // Confounded Face + "\ud83d\ude1e", // Disappointed Face + "\ud83d\ude1f", // Worried Face + "\ud83d\ude24", // Face With Look of Triumph + "\ud83d\ude22", // Crying Face + "\ud83d\ude2d", // Loudly Crying Face + "\ud83d\ude26", // Frowning Face With Open Mouth + "\ud83d\ude27", // Anguished Face + "\ud83d\ude28", // Fearful Face + "\ud83d\ude29", // Weary Face + "\ud83d\ude2c", // Grimacing Face + "\ud83d\ude30", // Face With Open Mouth and Cold Sweat + "\ud83d\ude31", // Face Screaming in Fear + "\ud83d\ude33", // Flushed Face + "\ud83d\ude35", // Dizzy Face + "\ud83d\ude21", // Pouting Face + "\ud83d\ude20", // Angry Face + "\ud83d\ude08", // Smiling Face With Horns + "\ud83d\udc7f", // Imp + "\ud83d\udc79", // Japanese Ogre + "\ud83d\udc7a", // Japanese Goblin + "\ud83d\udc80", // Skull + "\ud83d\udc7b", // Ghost + "\ud83d\udc7d", // Extraterrestrial Alien + "\ud83e\udd16", // Robot Face + "\ud83d\udca9", // Pile of Poo + "\ud83d\ude3a", // Smiling Cat Face With Open Mouth + "\ud83d\ude38", // Grinning Cat Face With Smiling Eyes + "\ud83d\ude39", // Cat Face With Tears of Joy + "\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes + "\ud83d\ude3c", // Cat Face With Wry Smile + "\ud83d\ude3d", // Kissing Cat Face With Closed Eyes + "\ud83d\ude40", // Weary Cat Face + "\ud83d\ude3f", // Crying Cat Face + "\ud83d\ude3e", // Pouting Cat Face + "\ud83d\udc66", // Boy + "\ud83d\udc67", // Girl + "\ud83d\udc68", // Man + "\ud83d\udc69", // Woman + "\ud83d\udc74", // Older Man + "\ud83d\udc75", // Older Woman + "\ud83d\udc76", // Baby + "\ud83d\udc71", // Person With Blond Hair + "\ud83d\udc6e", // Police Officer + "\ud83d\udc72", // Man With Gua Pi Mao + "\ud83d\udc73", // Man With Turban + "\ud83d\udc77", // Construction Worker + "\u26d1", // Helmet With White Cross + "\ud83d\udc78", // Princess + "\ud83d\udc82", // Guardsman + "\ud83d\udd75", // Sleuth or Spy + "\ud83c\udf85", // Father Christmas + "\ud83d\udc70", // Bride With Veil + "\ud83d\udc7c", // Baby Angel + "\ud83d\udc86", // Face Massage + "\ud83d\udc87", // Haircut + "\ud83d\ude4d", // Person Frowning + "\ud83d\ude4e", // Person With Pouting Face + "\ud83d\ude45", // Face With No Good Gesture + "\ud83d\ude46", // Face With OK Gesture + "\ud83d\udc81", // Information Desk Person + "\ud83d\ude4b", // Happy Person Raising One Hand + "\ud83d\ude47", // Person Bowing Deeply + "\ud83d\ude4c", // Person Raising Both Hands in Celebration + "\ud83d\ude4f", // Person With Folded Hands + "\ud83d\udde3", // Speaking Head in Silhouette + "\ud83d\udc64", // Bust in Silhouette + "\ud83d\udc65", // Busts in Silhouette + "\ud83d\udeb6", // Pedestrian + "\ud83c\udfc3", // Runner + "\ud83d\udc6f", // Woman With Bunny Ears + "\ud83d\udc83", // Dancer + "\ud83d\udd74", // Man in Business Suit Levitating + "\ud83d\udc6b", // Man and Woman Holding Hands + "\ud83d\udc6c", // Two Men Holding Hands + "\ud83d\udc6d", // Two Women Holding Hands + "\ud83d\udc8f", // Kiss +) From a0ab1bfadb200627bc08db1c9c0e83e3e7db8828 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Wed, 22 Jan 2025 14:41:29 -0600 Subject: [PATCH 07/49] Rebased through AS, hope I dont break it. --- app/build.gradle.kts | 5 +- .../main/java/com/OxGames/Pluvia/Constants.kt | 5 ++ .../java/com/OxGames/Pluvia/MainActivity.kt | 2 + .../java/com/OxGames/Pluvia/data/Emoticon.kt | 7 +++ .../com/OxGames/Pluvia/db/PluviaDatabase.kt | 6 ++- .../com/OxGames/Pluvia/db/dao/EmoticonDao.kt | 34 +++++++++++++ .../Pluvia/db/dao/FriendMessagesDao.kt | 2 +- .../com/OxGames/Pluvia/di/DatabaseModule.kt | 6 ++- .../OxGames/Pluvia/service/SteamService.kt | 22 ++++++++ .../service/callback/EmoticonListCallback.kt | 25 +++++++++ .../Pluvia/service/handler/PluviaHandler.kt | 39 ++++++++++++++ .../OxGames/Pluvia/ui/screen/HomeScreen.kt | 4 +- .../Pluvia/ui/screen/friends/ChatScreen.kt | 39 ++++++++++++-- ...{HomeFriendsScreen.kt => FriendsScreen.kt} | 46 ++++++++++++----- .../java/com/OxGames/Pluvia/ui/util/Images.kt | 42 +++++++++++++++ .../utils/{IconDecoder.kt => CoilDecoders.kt} | 51 ++++++++++++++++++- gradle/libs.versions.toml | 2 + 17 files changed, 312 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/data/Emoticon.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt rename app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/{HomeFriendsScreen.kt => FriendsScreen.kt} (81%) rename app/src/main/java/com/OxGames/Pluvia/utils/{IconDecoder.kt => CoilDecoders.kt} (54%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60d74734..940925db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -184,11 +184,12 @@ dependencies { // Support implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.apng) + implementation(libs.datastore.preferences) implementation(libs.jetbrains.kotlinx.json) implementation(libs.kotlin.coroutines) - implementation(libs.zxing) - implementation(libs.datastore.preferences) implementation(libs.timber) + implementation(libs.zxing) // Google Protobufs implementation(libs.protobuf.java) diff --git a/app/src/main/java/com/OxGames/Pluvia/Constants.kt b/app/src/main/java/com/OxGames/Pluvia/Constants.kt index 81b6fde1..2d09547d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/Constants.kt +++ b/app/src/main/java/com/OxGames/Pluvia/Constants.kt @@ -23,6 +23,11 @@ object Constants { const val PROFILE_URL = "https://steamcommunity.com/profiles/" } + object Chat { + const val EMOTICON_URL = "https://steamcommunity-a.akamaihd.net/economy/emoticonlarge/" + const val STICKER_URL = "https://steamcommunity-a.akamaihd.net/economy/sticker/" + } + object Misc { const val TIP_JAR_LINK = "https://buy.stripe.com/5kAaFU1bx2RFeLmbII" const val GITHUB_LINK = "https://github.com/oxters168/Pluvia" diff --git a/app/src/main/java/com/OxGames/Pluvia/MainActivity.kt b/app/src/main/java/com/OxGames/Pluvia/MainActivity.kt index d040e34e..d8064c4e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/MainActivity.kt +++ b/app/src/main/java/com/OxGames/Pluvia/MainActivity.kt @@ -31,6 +31,7 @@ import com.OxGames.Pluvia.events.AndroidEvent import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.PluviaMain import com.OxGames.Pluvia.ui.enums.Orientation +import com.OxGames.Pluvia.utils.AnimatedPngDecoder import com.OxGames.Pluvia.utils.IconDecoder import com.skydoves.landscapist.coil.LocalCoilImageLoader import com.winlator.core.AppUtils @@ -118,6 +119,7 @@ class MainActivity : ComponentActivity() { .diskCache(diskCache) .components { add(IconDecoder.Factory()) + add(AnimatedPngDecoder.Factory()) } .logger(logger) .build() diff --git a/app/src/main/java/com/OxGames/Pluvia/data/Emoticon.kt b/app/src/main/java/com/OxGames/Pluvia/data/Emoticon.kt new file mode 100644 index 00000000..51bd1ff6 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/data/Emoticon.kt @@ -0,0 +1,7 @@ +package com.OxGames.Pluvia.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "emoticon") +data class Emoticon(@PrimaryKey val name: String, val appID: Int, val isSticker: Boolean) diff --git a/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt b/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt index 5f7cfb44..8c621cd3 100644 --- a/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt +++ b/app/src/main/java/com/OxGames/Pluvia/db/PluviaDatabase.kt @@ -4,6 +4,7 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.OxGames.Pluvia.data.ChangeNumbers +import com.OxGames.Pluvia.data.Emoticon import com.OxGames.Pluvia.data.FileChangeLists import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.SteamFriend @@ -12,6 +13,7 @@ import com.OxGames.Pluvia.db.converters.FriendConverter import com.OxGames.Pluvia.db.converters.PathTypeConverter import com.OxGames.Pluvia.db.converters.UserFileInfoListConverter import com.OxGames.Pluvia.db.dao.ChangeNumbersDao +import com.OxGames.Pluvia.db.dao.EmoticonDao import com.OxGames.Pluvia.db.dao.FileChangeListsDao import com.OxGames.Pluvia.db.dao.FriendMessagesDao import com.OxGames.Pluvia.db.dao.SteamFriendDao @@ -19,7 +21,7 @@ import com.OxGames.Pluvia.db.dao.SteamFriendDao const val DATABASE_NAME = "pluvia.db" @Database( - entities = [SteamFriend::class, ChangeNumbers::class, FileChangeLists::class, FriendMessage::class], + entities = [SteamFriend::class, ChangeNumbers::class, FileChangeLists::class, FriendMessage::class, Emoticon::class], version = 2, exportSchema = false, // Should export once stable. ) @@ -38,4 +40,6 @@ abstract class PluviaDatabase : RoomDatabase() { abstract fun appFileChangeListsDao(): FileChangeListsDao abstract fun friendMessagesDao(): FriendMessagesDao + + abstract fun emoticonDao(): EmoticonDao } diff --git a/app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt b/app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt new file mode 100644 index 00000000..2630c9dd --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt @@ -0,0 +1,34 @@ +package com.OxGames.Pluvia.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.OxGames.Pluvia.data.Emoticon +import kotlinx.coroutines.flow.Flow + +@Dao +interface EmoticonDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(emoticons: List) + + @Query("SELECT * FROM emoticon ORDER BY isSticker DESC, appID DESC, name DESC") + fun getAll(): Flow> + + @Query("DELETE FROM emoticon") + suspend fun deleteAll() + + @Transaction + suspend fun replaceAll(emoticons: List) { + deleteAll() + insertAll(emoticons) + } + + @Query("SELECT COUNT(*) FROM emoticon") + fun getCount(): Flow + + @Query("SELECT * FROM emoticon WHERE isSticker = :isSticker ORDER BY name ASC") + fun getByType(isSticker: Boolean): Flow> +} diff --git a/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt b/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt index 3075df07..d0759687 100644 --- a/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt +++ b/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt @@ -27,7 +27,7 @@ interface FriendMessagesDao { suspend fun deleteAllMessagesForFriend(steamId: SteamID) @Query("SELECT * FROM chat_message WHERE steam_id_friend = :steamId ORDER BY timestamp DESC") - fun getAllMessagesForFriend(steamId: SteamID): Flow> + fun getAllMessagesForFriend(steamId: Long): Flow> @Update suspend fun updateMessage(message: FriendMessage) diff --git a/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt b/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt index 0c9c2e24..745edce9 100644 --- a/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt +++ b/app/src/main/java/com/OxGames/Pluvia/di/DatabaseModule.kt @@ -39,5 +39,9 @@ class DatabaseModule { @Provides @Singleton - fun provideFriendMessages(db: PluviaDatabase) = db.friendMessagesDao() + fun provideFriendMessagesDao(db: PluviaDatabase) = db.friendMessagesDao() + + @Provides + @Singleton + fun provideEmoticonDao(db: PluviaDatabase) = db.emoticonDao() } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 7f9c941d..3c6c0b3d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -27,6 +27,7 @@ import com.OxGames.Pluvia.data.UFS import com.OxGames.Pluvia.data.UserFileInfo import com.OxGames.Pluvia.db.PluviaDatabase import com.OxGames.Pluvia.db.dao.ChangeNumbersDao +import com.OxGames.Pluvia.db.dao.EmoticonDao import com.OxGames.Pluvia.db.dao.FileChangeListsDao import com.OxGames.Pluvia.db.dao.FriendMessagesDao import com.OxGames.Pluvia.db.dao.SteamFriendDao @@ -41,6 +42,8 @@ import com.OxGames.Pluvia.enums.SaveLocation import com.OxGames.Pluvia.enums.SyncResult import com.OxGames.Pluvia.events.AndroidEvent import com.OxGames.Pluvia.events.SteamEvent +import com.OxGames.Pluvia.service.callback.EmoticonListCallback +import com.OxGames.Pluvia.service.handler.PluviaHandler import com.OxGames.Pluvia.utils.FileUtils import com.OxGames.Pluvia.utils.SteamUtils import com.OxGames.Pluvia.utils.generateManifest @@ -139,6 +142,9 @@ class SteamService : Service(), IChallengeUrlChanged { @Inject lateinit var messagesDao: FriendMessagesDao + @Inject + lateinit var emoticonDao: EmoticonDao + @Inject lateinit var changeNumbersDao: ChangeNumbersDao @@ -997,6 +1003,10 @@ class SteamService : Service(), IChallengeUrlChanged { PluviaApp.events.emit(SteamEvent.LoggedOut(username)) } + + suspend fun getEmoticonList() = withContext(Dispatchers.IO) { + instance?.steamClient?.getHandler()?.getEmoticonList() ?: Timber.w("Failed to get emotes") + } } override fun onCreate() { @@ -1042,6 +1052,8 @@ class SteamService : Service(), IChallengeUrlChanged { // create our steam client instance steamClient = SteamClient(configuration) + steamClient!!.addHandler(PluviaHandler()) + // remove callbacks we're not using. steamClient!!.removeHandler(SteamGameServer::class.java) steamClient!!.removeHandler(SteamMasterServer::class.java) @@ -1072,6 +1084,7 @@ class SteamService : Service(), IChallengeUrlChanged { add(subscribe(PICSProductInfoCallback::class.java, ::onPICSProductInfo)) add(subscribe(NicknameListCallback::class.java, ::onNicknameList)) add(subscribe(FriendsListCallback::class.java, ::onFriendsList)) + add(subscribe(EmoticonListCallback::class.java, ::onEmoticonList)) } } @@ -1386,6 +1399,15 @@ class SteamService : Service(), IChallengeUrlChanged { } } + private fun onEmoticonList(callback: EmoticonListCallback) { + Timber.i("Getting emotes and stickers, size: ${callback.emoteList.size}") + dbScope.launch { + db.withTransaction { + emoticonDao.replaceAll(callback.emoteList) + } + } + } + @OptIn(ExperimentalStdlibApi::class) private fun onPersonaStateReceived(callback: PersonaStatesCallback) { // Ignore accounts that arent individuals diff --git a/app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt b/app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt new file mode 100644 index 00000000..ee95642a --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt @@ -0,0 +1,25 @@ +package com.OxGames.Pluvia.service.callback + +import com.OxGames.Pluvia.data.Emoticon +import `in`.dragonbra.javasteam.base.ClientMsgProtobuf +import `in`.dragonbra.javasteam.base.IPacketMsg +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverFriends.CMsgClientEmoticonList +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg + +class EmoticonListCallback(packetMsg: IPacketMsg) : CallbackMsg() { + + val emoteList: List + + init { + val resp = ClientMsgProtobuf( + CMsgClientEmoticonList::class.java, + packetMsg, + ) + jobID = resp.targetJobID + + emoteList = buildList { + addAll(resp.body.emoticonsList.map { Emoticon(name = it.name, appID = it.appid, isSticker = false) }) + addAll(resp.body.stickersList.map { Emoticon(name = it.name, appID = it.appid, isSticker = true) }) + } + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt b/app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt new file mode 100644 index 00000000..4dbe95a1 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt @@ -0,0 +1,39 @@ +package com.OxGames.Pluvia.service.handler + +import com.OxGames.Pluvia.service.callback.EmoticonListCallback +import `in`.dragonbra.javasteam.base.ClientMsgProtobuf +import `in`.dragonbra.javasteam.base.IPacketMsg +import `in`.dragonbra.javasteam.enums.EMsg +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverFriends.CMsgClientGetEmoticonList +import `in`.dragonbra.javasteam.steam.handlers.ClientMsgHandler +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg + +/** + * Custom handler to handle dispatching that JavaSteam does not support. + */ +class PluviaHandler : ClientMsgHandler() { + + companion object { + fun getCallback(packetMsg: IPacketMsg): CallbackMsg? = when (packetMsg.msgType) { + EMsg.ClientGetEmoticonList -> EmoticonListCallback(packetMsg) + else -> null + } + } + + /** + * Handles a client message. This should not be called directly. + * @param packetMsg The packet message that contains the data. + */ + override fun handleMsg(packetMsg: IPacketMsg) { + val callback = getCallback(packetMsg) ?: return + + client.postCallback(callback) + } + + fun getEmoticonList() { + ClientMsgProtobuf( + CMsgClientGetEmoticonList::class.java, + EMsg.ClientGetEmoticonList, + ).also(client::send) + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt index 321b1e87..6daa97e3 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt @@ -27,7 +27,7 @@ import com.OxGames.Pluvia.ui.component.dialog.MessageDialog import com.OxGames.Pluvia.ui.enums.HomeDestination import com.OxGames.Pluvia.ui.model.HomeViewModel import com.OxGames.Pluvia.ui.screen.downloads.HomeDownloadsScreen -import com.OxGames.Pluvia.ui.screen.friends.HomeFriendsScreen +import com.OxGames.Pluvia.ui.screen.friends.FriendsScreen import com.OxGames.Pluvia.ui.screen.library.HomeLibraryScreen import com.OxGames.Pluvia.ui.theme.PluviaTheme @@ -98,7 +98,7 @@ private fun HomeScreenContent( onLogout = onLogout, ) - HomeDestination.Friends -> HomeFriendsScreen( + HomeDestination.Friends -> FriendsScreen( onSettings = onSettings, onLogout = onLogout, ) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt index 9d3b3ca4..d65bf567 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt @@ -52,10 +52,14 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.OxGames.Pluvia.data.Emoticon import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.SteamFriend +import com.OxGames.Pluvia.db.dao.EmoticonDao import com.OxGames.Pluvia.db.dao.FriendMessagesDao import com.OxGames.Pluvia.db.dao.SteamFriendDao +import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.ui.util.ListItemImage @@ -71,29 +75,58 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch data class ChatState( val friend: SteamFriend = SteamFriend(0), val messages: List = listOf(), + val emoticons: List = listOf(), ) @HiltViewModel class ChatViewModel @Inject constructor( - friendDao: SteamFriendDao, - messagesDao: FriendMessagesDao, + private val friendDao: SteamFriendDao, + private val messagesDao: FriendMessagesDao, + private val emoticonDao: EmoticonDao, ) : ViewModel() { private val _chatState = MutableStateFlow(ChatState()) val chatState: StateFlow = _chatState.asStateFlow() + + fun setFriend(id: Long) { + viewModelScope.launch { + SteamService.getEmoticonList() + + emoticonDao.getAll().collect { list -> + _chatState.update { it.copy(emoticons = list) } + } + + friendDao.findFriend(id).collect { friend -> + if (friend == null) { + throw RuntimeException("Friend is null and cannot proceed") + } + + _chatState.update { it.copy(friend = friend) } + } + + messagesDao.getAllMessagesForFriend(id).collect { list -> + _chatState.update { it.copy(messages = list) } + } + } + } } @Composable fun ChatScreen( - viewModel: ChatViewModel = hiltViewModel(), + steamFriend: SteamFriend, + viewModel: ChatViewModel = hiltViewModel(key = steamFriend.id.toString()), onBack: () -> Unit, ) { val state by viewModel.chatState.collectAsStateWithLifecycle() + viewModel.setFriend(steamFriend.id) + ChatScreenContent( steamFriend = state.friend, messages = state.messages, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/HomeFriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt similarity index 81% rename from app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/HomeFriendsScreen.kt rename to app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 10425fc5..e90f62c6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/HomeFriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -17,10 +17,12 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable @@ -43,7 +45,7 @@ import `in`.dragonbra.javasteam.types.SteamID import kotlinx.coroutines.launch @Composable -fun HomeFriendsScreen( +fun FriendsScreen( viewModel: FriendsViewModel = hiltViewModel(), onSettings: () -> Unit, onLogout: () -> Unit, @@ -70,7 +72,7 @@ private fun FriendsScreenContent( ) { val snackbarHost = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val navigator = rememberListDetailPaneScaffoldNavigator() + val navigator = rememberListDetailPaneScaffoldNavigator() // Pretty much the same as 'NavigableListDetailPaneScaffold' BackHandler(navigator.canNavigateBack(BackNavigationBehavior.PopUntilContentChange)) { @@ -102,28 +104,42 @@ private fun FriendsScreenContent( paddingValues = paddingValues, list = state.friendsList, onItemClick = { - scope.launch { - snackbarHost.showSnackbar("TODO Chat") - // navigator.navigateTo( - // ListDetailPaneScaffoldRole.Detail, - // SteamID(1L)) - } + navigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + it, + ) }, ) } } }, detailPane = { - val value = navigator.currentDestination?.content ?: SteamID() + val value = navigator.currentDestination?.content ?: SteamFriend(0) AnimatedPane { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = { - if (value.convertToUInt64() == 0L) { - Text("Choose something from Friends") + if (value.id == 0L) { + Surface( + modifier = Modifier.padding(horizontal = 24.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 8.dp, + ) { + Text( + modifier = Modifier.padding(24.dp), + text = "Select a friend to message", + ) + } } else { - Text("Hi Friend $value") + ChatScreen( + steamFriend = value, + onBack = { + // We're still in Adaptive navigation. + navigator.navigateBack() + }, + ) } }, ) @@ -137,7 +153,7 @@ private fun FriendsScreenContent( private fun FriendsListPane( paddingValues: PaddingValues, list: Map>, - onItemClick: () -> Unit, + onItemClick: (SteamFriend) -> Unit, ) { LazyColumn( modifier = Modifier @@ -159,7 +175,9 @@ private fun FriendsListPane( FriendItem( modifier = Modifier.animateItem(), friend = item, - onClick = onItemClick, + onClick = { + onItemClick(item) + }, ) if (idx < value.lastIndex) { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt b/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt index 9d4aa145..2d5f4911 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt @@ -44,6 +44,32 @@ internal fun ListItemImage( ) } +@Composable +fun EmoticonImage( + size: Dp = 54.dp, + image: () -> Any?, +) { + CoilImage( + modifier = Modifier.size(size), // TODO may not be pixel perfect + imageModel = image, + loading = { + CircularProgressIndicator() + }, + failure = { + Icon(Icons.Filled.QuestionMark, null) + }, + previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), + ) +} + +@Composable +fun StickerImage( + size: Dp = 150.dp, + image: () -> Any?, +) { + EmoticonImage(size, image) +} + @Preview @Composable private fun Preview_ListItemImage() { @@ -51,3 +77,19 @@ private fun Preview_ListItemImage() { ListItemImage { } } } + +@Preview +@Composable +private fun Preview_EmoticonImage() { + PluviaTheme { + EmoticonImage { "https://steamcommunity-a.akamaihd.net/economy/emoticonlarge/roar" } + } +} + +@Preview +@Composable +private fun Preview_StickerImage() { + PluviaTheme { + StickerImage { "https://steamcommunity-a.akamaihd.net/economy/sticker/Delivery%20Cat%20in%20a%20Blanket" } + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/IconDecoder.kt b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt similarity index 54% rename from app/src/main/java/com/OxGames/Pluvia/utils/IconDecoder.kt rename to app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt index f55d4ec3..7624301d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/IconDecoder.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt @@ -5,14 +5,21 @@ import android.graphics.drawable.BitmapDrawable import coil.ImageLoader import coil.decode.DecodeResult import coil.decode.Decoder +import coil.decode.ImageSource import coil.fetch.SourceResult import coil.request.Options +import com.github.penfeizhou.animation.apng.APNGDrawable +import com.github.penfeizhou.animation.apng.decode.APNGParser +import com.github.penfeizhou.animation.io.ByteBufferReader +import com.github.penfeizhou.animation.io.StreamReader +import com.github.penfeizhou.animation.loader.Loader +import java.nio.ByteBuffer import okio.BufferedSource import okio.ByteString.Companion.toByteString import timber.log.Timber /** - * Coil .ico file decoder + * Coil ICO file decoder */ class IconDecoder( private val source: SourceResult, @@ -60,3 +67,45 @@ class IconDecoder( } } } + +/** + * Coil APNG file decoder + * From https://github.com/coil-kt/coil/issues/506#issuecomment-952526682 + */ +class AnimatedPngDecoder(private val source: ImageSource) : Decoder { + override suspend fun decode(): DecodeResult { + val buffer = source.source().squashToDirectByteBuffer() + return DecodeResult( + drawable = APNGDrawable(Loader { ByteBufferReader(buffer) }), + isSampled = false, + ) + } + + private fun BufferedSource.squashToDirectByteBuffer(): ByteBuffer { + request(Long.MAX_VALUE) + + val byteBuffer = ByteBuffer.allocateDirect(buffer.size.toInt()) + while (!buffer.exhausted()) { + buffer.read(byteBuffer) + } + + byteBuffer.flip() + + return byteBuffer + } + + class Factory : Decoder.Factory { + override fun create( + result: SourceResult, + options: Options, + imageLoader: ImageLoader, + ): Decoder? { + val stream = result.source.source().peek().inputStream() + return if (APNGParser.isAPNG(StreamReader(stream))) { + AnimatedPngDecoder(result.source) + } else { + null + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0996f315..121da9bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ activityCompose = "1.10.0" # https://mvnrepository.com/artifact/androidx.activity/activity-compose agp = "8.7.3" # https://mvnrepository.com/artifact/com.android.application/com.android.application.gradle.plugin apache-compress = "1.27.1" # https://mvnrepository.com/artifact/org.apache.commons/commons-compress +apng = "3.0.2" # https://mvnrepository.com/artifact/com.github.penfeizhou.android.animation/apng composeBom = "2025.01.00" # https://mvnrepository.com/artifact/androidx.compose/compose-bom coreKtx = "1.15.0" # https://mvnrepository.com/artifact/androidx.core/core-ktx coroutines = "1.10.1" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core @@ -52,6 +53,7 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui", version = "1.8.0-alp androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +apng = { group = "com.github.penfeizhou.android.animation", name = "apng", version.ref = "apng" } apache-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "apache-compress" } compose-settings-ui = { module = "com.github.alorma.compose-settings:ui-tiles", version.ref = "settings" } compose-settings-ui-extended = { module = "com.github.alorma.compose-settings:ui-tiles-extended", version.ref = "settings" } From 7833963beda56dc2157b51377210ab6e6f6042cc Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 23 Jan 2025 20:48:29 -0600 Subject: [PATCH 08/49] Move chat to its own package --- .../com/OxGames/Pluvia/ui/screen/{friends => chat}/ChatInput.kt | 2 +- .../Pluvia/ui/screen/{friends => chat}/ChatMessageItem.kt | 2 +- .../OxGames/Pluvia/ui/screen/{friends => chat}/ChatScreen.kt | 2 +- .../java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) rename app/src/main/java/com/OxGames/Pluvia/ui/screen/{friends => chat}/ChatInput.kt (99%) rename app/src/main/java/com/OxGames/Pluvia/ui/screen/{friends => chat}/ChatMessageItem.kt (98%) rename app/src/main/java/com/OxGames/Pluvia/ui/screen/{friends => chat}/ChatScreen.kt (99%) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt similarity index 99% rename from app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatInput.kt rename to app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt index 696b61e1..ea60cbf2 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatInput.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt @@ -1,4 +1,4 @@ -package com.OxGames.Pluvia.ui.screen.friends +package com.OxGames.Pluvia.ui.screen.chat import android.content.res.Configuration import androidx.activity.compose.BackHandler diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatMessageItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatMessageItem.kt similarity index 98% rename from app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatMessageItem.kt rename to app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatMessageItem.kt index 29be5c7e..1e99b3bf 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatMessageItem.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatMessageItem.kt @@ -1,4 +1,4 @@ -package com.OxGames.Pluvia.ui.screen.friends +package com.OxGames.Pluvia.ui.screen.chat import android.content.res.Configuration import androidx.compose.foundation.background diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt similarity index 99% rename from app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt rename to app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index d65bf567..eea79e9b 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -1,4 +1,4 @@ -package com.OxGames.Pluvia.ui.screen.friends +package com.OxGames.Pluvia.ui.screen.chat import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index e90f62c6..042dd29e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -40,6 +40,7 @@ import com.OxGames.Pluvia.ui.component.topbar.AccountButton import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.data.FriendsState import com.OxGames.Pluvia.ui.model.FriendsViewModel +import com.OxGames.Pluvia.ui.screen.chat.ChatScreen import com.OxGames.Pluvia.ui.theme.PluviaTheme import `in`.dragonbra.javasteam.types.SteamID import kotlinx.coroutines.launch From e5265f2c53a7198cfeb6364d8bf3b42e7d14bd70 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 23 Jan 2025 21:10:30 -0600 Subject: [PATCH 09/49] Add PreviewParameter to FriendsScreen --- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 98 +++++++++++-------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 042dd29e..baea1fa7 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -3,7 +3,6 @@ package com.OxGames.Pluvia.ui.screen.friends import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.displayCutoutPadding @@ -23,15 +22,18 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -40,11 +42,11 @@ import com.OxGames.Pluvia.ui.component.topbar.AccountButton import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.data.FriendsState import com.OxGames.Pluvia.ui.model.FriendsViewModel -import com.OxGames.Pluvia.ui.screen.chat.ChatScreen import com.OxGames.Pluvia.ui.theme.PluviaTheme import `in`.dragonbra.javasteam.types.SteamID import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun FriendsScreen( viewModel: FriendsViewModel = hiltViewModel(), @@ -52,10 +54,12 @@ fun FriendsScreen( onLogout: () -> Unit, ) { val state by viewModel.friendsState.collectAsStateWithLifecycle() + val navigator = rememberListDetailPaneScaffoldNavigator() val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher FriendsScreenContent( + navigator = navigator, state = state, onBack = { onBackPressedDispatcher?.onBackPressed() }, onSettings = onSettings, @@ -66,14 +70,13 @@ fun FriendsScreen( @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun FriendsScreenContent( + navigator: ThreePaneScaffoldNavigator, state: FriendsState, onBack: () -> Unit, onSettings: () -> Unit, onLogout: () -> Unit, ) { val snackbarHost = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - val navigator = rememberListDetailPaneScaffoldNavigator() // Pretty much the same as 'NavigableListDetailPaneScaffold' BackHandler(navigator.canNavigateBack(BackNavigationBehavior.PopUntilContentChange)) { @@ -105,10 +108,7 @@ private fun FriendsScreenContent( paddingValues = paddingValues, list = state.friendsList, onItemClick = { - navigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - it, - ) + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) }, ) } @@ -117,39 +117,34 @@ private fun FriendsScreenContent( detailPane = { val value = navigator.currentDestination?.content ?: SteamFriend(0) AnimatedPane { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - content = { - if (value.id == 0L) { - Surface( - modifier = Modifier.padding(horizontal = 24.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - shadowElevation = 8.dp, - ) { - Text( - modifier = Modifier.padding(24.dp), - text = "Select a friend to message", - ) + Surface { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + content = { + if (value.id == 0L) { + Surface( + modifier = Modifier.padding(horizontal = 24.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 8.dp, + ) { + Text( + modifier = Modifier.padding(24.dp), + text = "Select a friend to their profile", + ) + } + } else { + // TODO profile screen } - } else { - ChatScreen( - steamFriend = value, - onBack = { - // We're still in Adaptive navigation. - navigator.navigateBack() - }, - ) - } - }, - ) + }, + ) + } } }, ) } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun FriendsListPane( paddingValues: PaddingValues, @@ -168,7 +163,9 @@ private fun FriendsListPane( isCollapsed = false, header = key, count = value.size, - onHeaderAction = { }, + onHeaderAction = { + // TODO (Un)Collapse children items. + }, ) } @@ -176,9 +173,7 @@ private fun FriendsListPane( FriendItem( modifier = Modifier.animateItem(), friend = item, - onClick = { - onItemClick(item) - }, + onClick = { onItemClick(item) }, ) if (idx < value.lastIndex) { @@ -189,11 +184,32 @@ private fun FriendsListPane( } } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +internal class FriendsScreenPreview : PreviewParameterProvider> { + override val values: Sequence> + get() = sequenceOf( + ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), + ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail, SteamFriend(123L)), + ) +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + device = "spec:parent=pixel_5,orientation=landscape", +) @Composable -private fun Preview_FriendsScreenContent() { +private fun Preview_FriendsScreenContent( + @PreviewParameter(FriendsScreenPreview::class) state: ThreePaneScaffoldDestinationItem, +) { + val navigator = rememberListDetailPaneScaffoldNavigator( + initialDestinationHistory = listOf(state), + ) + PluviaTheme { FriendsScreenContent( + navigator = navigator, state = FriendsState( friendsList = mapOf( "TEST A" to List(3) { SteamFriend(id = it.toLong()) }, From 8bce11e80b7f5d50b297d19fcd5781adf40d9b5f Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 24 Jan 2025 00:20:47 -0600 Subject: [PATCH 10/49] Rebase another conflict --- .../com/OxGames/Pluvia/data/SteamFriend.kt | 7 + .../Pluvia/ui/screen/chat/ChatScreen.kt | 7 +- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 219 ++++++++++++++++-- 3 files changed, 210 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt index c762f38d..4c8e6629 100644 --- a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt +++ b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt @@ -88,6 +88,13 @@ data class SteamFriend( val isPlayingGame: Boolean get() = if (isOnline) gameAppID > 0 || gameName.isEmpty().not() else false + val isPlayingGameName: String + get() = if (isPlayingGame) { + gameName.ifEmpty { "Playing game id: $gameAppID" } + } else { + state.name + } + val isAwayOrSnooze: Boolean get() = state.let { it == EPersonaState.Away || it == EPersonaState.Snooze || it == EPersonaState.Busy diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index eea79e9b..3fd3cd1f 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -199,12 +199,7 @@ private fun ChatScreenContent( ) Text( - text = if (steamFriend.isPlayingGame) { - // TODO get game names - steamFriend.gameName.ifEmpty { "Playing game id: ${steamFriend.gameAppID}" } - } else { - steamFriend.state.name - }, + text = steamFriend.isPlayingGameName, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, maxLines = 1, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index baea1fa7..fa479641 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -3,15 +3,40 @@ package com.OxGames.Pluvia.ui.screen.friends import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.outlined.Games +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Card import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -19,6 +44,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole @@ -27,16 +53,25 @@ import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowWidthSizeClass +import com.OxGames.Pluvia.R import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.ui.component.topbar.AccountButton import com.OxGames.Pluvia.ui.component.topbar.BackButton @@ -45,8 +80,11 @@ import com.OxGames.Pluvia.ui.model.FriendsViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme import `in`.dragonbra.javasteam.types.SteamID import kotlinx.coroutines.launch +import com.OxGames.Pluvia.utils.getAvatarURL +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage +import `in`.dragonbra.javasteam.enums.EPersonaState -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun FriendsScreen( viewModel: FriendsViewModel = hiltViewModel(), @@ -67,7 +105,7 @@ fun FriendsScreen( ) } -@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FriendsScreenContent( navigator: ThreePaneScaffoldNavigator, @@ -123,19 +161,12 @@ private fun FriendsScreenContent( contentAlignment = Alignment.Center, content = { if (value.id == 0L) { - Surface( - modifier = Modifier.padding(horizontal = 24.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - shadowElevation = 8.dp, - ) { - Text( - modifier = Modifier.padding(24.dp), - text = "Select a friend to their profile", - ) - } + DefaultDetailsScreen() } else { - // TODO profile screen + ProfileDetailsScreen( + friend = value, + onBack = onBack, + ) } }, ) @@ -145,6 +176,155 @@ private fun FriendsScreenContent( ) } +@Composable +private fun DefaultDetailsScreen() { + Surface( + modifier = Modifier.padding(horizontal = 24.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 8.dp, + ) { + Text( + modifier = Modifier.padding(24.dp), + text = "Select a friend to their profile", + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProfileDetailsScreen( + friend: SteamFriend, + onBack: () -> Unit, +) { + val scrollState = rememberScrollState() + val windowWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + + // TODO placeholders + val steamLevel = 45 + val favoriteBadge = "Years of Service" + val favoriteBadgeIcon = R.drawable.icon_background_gold + + LaunchedEffect(friend) { + // TODO This isn't live data + } + + Scaffold( + topBar = { + // Show Top App Bar when in Compact or Medium screen space. + if (windowWidth == WindowWidthSizeClass.COMPACT || windowWidth == WindowWidthSizeClass.MEDIUM) { + CenterAlignedTopAppBar( + title = { + Text( + text = "Profile", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + BackButton(onClick = onBack) + }, + ) + } + }, + ) { paddingValues -> + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(paddingValues) + .padding(horizontal = 16.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + CoilImage( + modifier = Modifier + .padding(horizontal = 16.dp) + .clip(CircleShape) + .background(Color.DarkGray) + .size(92.dp), + imageModel = { friend.avatarHash.getAvatarURL() }, + imageOptions = ImageOptions( + contentScale = ContentScale.Crop, + contentDescription = null, + ), + loading = { CircularProgressIndicator() }, + failure = { Icon(Icons.Filled.QuestionMark, null) }, + previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = friend.nameOrNickname, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineLarge, + ) + + Text( + text = friend.isPlayingGameName, + color = friend.statusColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val cardItem: @Composable (String, ImageVector, () -> Unit) -> Unit = { text, icon, onClick -> + Card( + modifier = Modifier + .size(72.dp) + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + cardItem("Chat", Icons.AutoMirrored.Outlined.Chat) { + // TODO click + } + Spacer(modifier = Modifier.width(16.dp)) + cardItem("Profile", Icons.Outlined.Person) { + // TODO click + } + Spacer(modifier = Modifier.width(16.dp)) + cardItem("Games", Icons.Outlined.Games) { + // TODO click + } + Spacer(modifier = Modifier.width(16.dp)) + cardItem("Options", Icons.Outlined.MoreVert) { + // TODO click + } + } + } + } +} + @Composable private fun FriendsListPane( paddingValues: PaddingValues, @@ -184,16 +364,21 @@ private fun FriendsListPane( } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) internal class FriendsScreenPreview : PreviewParameterProvider> { + private val friend = SteamFriend( + id = 123L, + nickname = "Pluvia".repeat(3).trimEnd(), + state = EPersonaState.Online, + gameName = "Left 4 Dead 2", + ) + override val values: Sequence> get() = sequenceOf( ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail, SteamFriend(123L)), + ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail, friend), ) } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, From b1f8c41d57168ea6f7eace8e978e22ed15ba6be9 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Sun, 26 Jan 2025 14:58:27 -0600 Subject: [PATCH 11/49] Add ExperimentalMaterial3AdaptiveApi to composables. --- .../com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index fa479641..29cc7310 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -85,6 +85,7 @@ import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import `in`.dragonbra.javasteam.enums.EPersonaState +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun FriendsScreen( viewModel: FriendsViewModel = hiltViewModel(), @@ -105,7 +106,7 @@ fun FriendsScreen( ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) @Composable private fun FriendsScreenContent( navigator: ThreePaneScaffoldNavigator, @@ -364,6 +365,7 @@ private fun FriendsListPane( } } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) internal class FriendsScreenPreview : PreviewParameterProvider> { private val friend = SteamFriend( id = 123L, @@ -379,6 +381,7 @@ internal class FriendsScreenPreview : PreviewParameterProvider Date: Sun, 26 Jan 2025 15:06:30 -0600 Subject: [PATCH 12/49] Post rebase lint --- .../com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 29cc7310..22fe8b17 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,13 +31,12 @@ import androidx.compose.material.icons.outlined.Games import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.Card -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -78,8 +77,6 @@ import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.data.FriendsState import com.OxGames.Pluvia.ui.model.FriendsViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme -import `in`.dragonbra.javasteam.types.SteamID -import kotlinx.coroutines.launch import com.OxGames.Pluvia.utils.getAvatarURL import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage From 723e72bd381d7e974ccbdedfbb4dea10384e4d4e Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Sun, 26 Jan 2025 16:34:05 -0600 Subject: [PATCH 13/49] Update gradle --- .../com/OxGames/Pluvia/utils/CoilDecoders.kt | 2 +- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 284 +++++++++++------- gradlew.bat | 37 ++- 6 files changed, 201 insertions(+), 129 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt index 7624301d..a7174261 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt @@ -76,7 +76,7 @@ class AnimatedPngDecoder(private val source: ImageSource) : Decoder { override suspend fun decode(): DecodeResult { val buffer = source.source().squashToDirectByteBuffer() return DecodeResult( - drawable = APNGDrawable(Loader { ByteBufferReader(buffer) }), + drawable = APNGDrawable { ByteBufferReader(buffer) }, isSampled = false, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 121da9bc..1a4bcbd9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activityCompose = "1.10.0" # https://mvnrepository.com/artifact/androidx.activity/activity-compose -agp = "8.7.3" # https://mvnrepository.com/artifact/com.android.application/com.android.application.gradle.plugin +agp = "8.8.0" # https://mvnrepository.com/artifact/com.android.application/com.android.application.gradle.plugin apache-compress = "1.27.1" # https://mvnrepository.com/artifact/org.apache.commons/commons-compress apng = "3.0.2" # https://mvnrepository.com/artifact/com.github.penfeizhou.android.animation/apng composeBom = "2025.01.00" # https://mvnrepository.com/artifact/androidx.compose/compose-bom diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-Vi3+ZOI=+qP}n zw(+!WcTd~4ZJX1!ZM&y!+uyt=&i!+~d(V%GjH;-NsEEv6nS1TERt|RHh!0>W4+4pp z1-*EzAM~i`+1f(VEHI8So`S`akPfPTfq*`l{Fz`hS%k#JS0cjT2mS0#QLGf=J?1`he3W*;m4)ce8*WFq1sdP=~$5RlH1EdWm|~dCvKOi4*I_96{^95p#B<(n!d?B z=o`0{t+&OMwKcxiBECznJcfH!fL(z3OvmxP#oWd48|mMjpE||zdiTBdWelj8&Qosv zZFp@&UgXuvJw5y=q6*28AtxZzo-UUpkRW%ne+Ylf!V-0+uQXBW=5S1o#6LXNtY5!I z%Rkz#(S8Pjz*P7bqB6L|M#Er{|QLae-Y{KA>`^} z@lPjeX>90X|34S-7}ZVXe{wEei1<{*e8T-Nbj8JmD4iwcE+Hg_zhkPVm#=@b$;)h6 z<<6y`nPa`f3I6`!28d@kdM{uJOgM%`EvlQ5B2bL)Sl=|y@YB3KeOzz=9cUW3clPAU z^sYc}xf9{4Oj?L5MOlYxR{+>w=vJjvbyO5}ptT(o6dR|ygO$)nVCvNGnq(6;bHlBd zl?w-|plD8spjDF03g5ip;W3Z z><0{BCq!Dw;h5~#1BuQilq*TwEu)qy50@+BE4bX28+7erX{BD4H)N+7U`AVEuREE8 z;X?~fyhF-x_sRfHIj~6f(+^@H)D=ngP;mwJjxhQUbUdzk8f94Ab%59-eRIq?ZKrwD z(BFI=)xrUlgu(b|hAysqK<}8bslmNNeD=#JW*}^~Nrswn^xw*nL@Tx!49bfJecV&KC2G4q5a!NSv)06A_5N3Y?veAz;Gv+@U3R% z)~UA8-0LvVE{}8LVDOHzp~2twReqf}ODIyXMM6=W>kL|OHcx9P%+aJGYi_Om)b!xe zF40Vntn0+VP>o<$AtP&JANjXBn7$}C@{+@3I@cqlwR2MdwGhVPxlTIcRVu@Ho-wO` z_~Or~IMG)A_`6-p)KPS@cT9mu9RGA>dVh5wY$NM9-^c@N=hcNaw4ITjm;iWSP^ZX| z)_XpaI61<+La+U&&%2a z0za$)-wZP@mwSELo#3!PGTt$uy0C(nTT@9NX*r3Ctw6J~7A(m#8fE)0RBd`TdKfAT zCf@$MAxjP`O(u9s@c0Fd@|}UQ6qp)O5Q5DPCeE6mSIh|Rj{$cAVIWsA=xPKVKxdhg zLzPZ`3CS+KIO;T}0Ip!fAUaNU>++ZJZRk@I(h<)RsJUhZ&Ru9*!4Ptn;gX^~4E8W^TSR&~3BAZc#HquXn)OW|TJ`CTahk+{qe`5+ixON^zA9IFd8)kc%*!AiLu z>`SFoZ5bW-%7}xZ>gpJcx_hpF$2l+533{gW{a7ce^B9sIdmLrI0)4yivZ^(Vh@-1q zFT!NQK$Iz^xu%|EOK=n>ug;(7J4OnS$;yWmq>A;hsD_0oAbLYhW^1Vdt9>;(JIYjf zdb+&f&D4@4AS?!*XpH>8egQvSVX`36jMd>$+RgI|pEg))^djhGSo&#lhS~9%NuWfX zDDH;3T*GzRT@5=7ibO>N-6_XPBYxno@mD_3I#rDD?iADxX`! zh*v8^i*JEMzyN#bGEBz7;UYXki*Xr(9xXax(_1qVW=Ml)kSuvK$coq2A(5ZGhs_pF z$*w}FbN6+QDseuB9=fdp_MTs)nQf!2SlROQ!gBJBCXD&@-VurqHj0wm@LWX-TDmS= z71M__vAok|@!qgi#H&H%Vg-((ZfxPAL8AI{x|VV!9)ZE}_l>iWk8UPTGHs*?u7RfP z5MC&=c6X;XlUzrz5q?(!eO@~* zoh2I*%J7dF!!_!vXoSIn5o|wj1#_>K*&CIn{qSaRc&iFVxt*^20ngCL;QonIS>I5^ zMw8HXm>W0PGd*}Ko)f|~dDd%;Wu_RWI_d;&2g6R3S63Uzjd7dn%Svu-OKpx*o|N>F zZg=-~qLb~VRLpv`k zWSdfHh@?dp=s_X`{yxOlxE$4iuyS;Z-x!*E6eqmEm*j2bE@=ZI0YZ5%Yj29!5+J$4h{s($nakA`xgbO8w zi=*r}PWz#lTL_DSAu1?f%-2OjD}NHXp4pXOsCW;DS@BC3h-q4_l`<))8WgzkdXg3! zs1WMt32kS2E#L0p_|x+x**TFV=gn`m9BWlzF{b%6j-odf4{7a4y4Uaef@YaeuPhU8 zHBvRqN^;$Jizy+ z=zW{E5<>2gp$pH{M@S*!sJVQU)b*J5*bX4h>5VJve#Q6ga}cQ&iL#=(u+KroWrxa%8&~p{WEUF0il=db;-$=A;&9M{Rq`ouZ5m%BHT6%st%saGsD6)fQgLN}x@d3q>FC;=f%O3Cyg=Ke@Gh`XW za@RajqOE9UB6eE=zhG%|dYS)IW)&y&Id2n7r)6p_)vlRP7NJL(x4UbhlcFXWT8?K=%s7;z?Vjts?y2+r|uk8Wt(DM*73^W%pAkZa1Jd zNoE)8FvQA>Z`eR5Z@Ig6kS5?0h;`Y&OL2D&xnnAUzQz{YSdh0k zB3exx%A2TyI)M*EM6htrxSlep!Kk(P(VP`$p0G~f$smld6W1r_Z+o?=IB@^weq>5VYsYZZR@` z&XJFxd5{|KPZmVOSxc@^%71C@;z}}WhbF9p!%yLj3j%YOlPL5s>7I3vj25 z@xmf=*z%Wb4;Va6SDk9cv|r*lhZ`(y_*M@>q;wrn)oQx%B(2A$9(74>;$zmQ!4fN; z>XurIk-7@wZys<+7XL@0Fhe-f%*=(weaQEdR9Eh6>Kl-EcI({qoZqyzziGwpg-GM#251sK_ z=3|kitS!j%;fpc@oWn65SEL73^N&t>Ix37xgs= zYG%eQDJc|rqHFia0!_sm7`@lvcv)gfy(+KXA@E{3t1DaZ$DijWAcA)E0@X?2ziJ{v z&KOYZ|DdkM{}t+@{@*6ge}m%xfjIxi%qh`=^2Rwz@w0cCvZ&Tc#UmCDbVwABrON^x zEBK43FO@weA8s7zggCOWhMvGGE`baZ62cC)VHyy!5Zbt%ieH+XN|OLbAFPZWyC6)p z4P3%8sq9HdS3=ih^0OOlqTPbKuzQ?lBEI{w^ReUO{V?@`ARsL|S*%yOS=Z%sF)>-y z(LAQdhgAcuF6LQjRYfdbD1g4o%tV4EiK&ElLB&^VZHbrV1K>tHTO{#XTo>)2UMm`2 z^t4s;vnMQgf-njU-RVBRw0P0-m#d-u`(kq7NL&2T)TjI_@iKuPAK-@oH(J8?%(e!0Ir$yG32@CGUPn5w4)+9@8c&pGx z+K3GKESI4*`tYlmMHt@br;jBWTei&(a=iYslc^c#RU3Q&sYp zSG){)V<(g7+8W!Wxeb5zJb4XE{I|&Y4UrFWr%LHkdQ;~XU zgy^dH-Z3lmY+0G~?DrC_S4@=>0oM8Isw%g(id10gWkoz2Q%7W$bFk@mIzTCcIB(K8 zc<5h&ZzCdT=9n-D>&a8vl+=ZF*`uTvQviG_bLde*k>{^)&0o*b05x$MO3gVLUx`xZ z43j+>!u?XV)Yp@MmG%Y`+COH2?nQcMrQ%k~6#O%PeD_WvFO~Kct za4XoCM_X!c5vhRkIdV=xUB3xI2NNStK*8_Zl!cFjOvp-AY=D;5{uXj}GV{LK1~IE2 z|KffUiBaStRr;10R~K2VVtf{TzM7FaPm;Y(zQjILn+tIPSrJh&EMf6evaBKIvi42-WYU9Vhj~3< zZSM-B;E`g_o8_XTM9IzEL=9Lb^SPhe(f(-`Yh=X6O7+6ALXnTcUFpI>ekl6v)ZQeNCg2 z^H|{SKXHU*%nBQ@I3It0m^h+6tvI@FS=MYS$ZpBaG7j#V@P2ZuYySbp@hA# ze(kc;P4i_-_UDP?%<6>%tTRih6VBgScKU^BV6Aoeg6Uh(W^#J^V$Xo^4#Ekp ztqQVK^g9gKMTHvV7nb64UU7p~!B?>Y0oFH5T7#BSW#YfSB@5PtE~#SCCg3p^o=NkMk$<8- z6PT*yIKGrvne7+y3}_!AC8NNeI?iTY(&nakN>>U-zT0wzZf-RuyZk^X9H-DT_*wk= z;&0}6LsGtfVa1q)CEUPlx#(ED@-?H<1_FrHU#z5^P3lEB|qsxEyn%FOpjx z3S?~gvoXy~L(Q{Jh6*i~=f%9kM1>RGjBzQh_SaIDfSU_9!<>*Pm>l)cJD@wlyxpBV z4Fmhc2q=R_wHCEK69<*wG%}mgD1=FHi4h!98B-*vMu4ZGW~%IrYSLGU{^TuseqVgV zLP<%wirIL`VLyJv9XG_p8w@Q4HzNt-o;U@Au{7%Ji;53!7V8Rv0^Lu^Vf*sL>R(;c zQG_ZuFl)Mh-xEIkGu}?_(HwkB2jS;HdPLSxVU&Jxy9*XRG~^HY(f0g8Q}iqnVmgjI zfd=``2&8GsycjR?M%(zMjn;tn9agcq;&rR!Hp z$B*gzHsQ~aXw8c|a(L^LW(|`yGc!qOnV(ZjU_Q-4z1&0;jG&vAKuNG=F|H?@m5^N@ zq{E!1n;)kNTJ>|Hb2ODt-7U~-MOIFo%9I)_@7fnX+eMMNh>)V$IXesJpBn|uo8f~#aOFytCT zf9&%MCLf8mp4kwHTcojWmM3LU=#|{3L>E}SKwOd?%{HogCZ_Z1BSA}P#O(%H$;z7XyJ^sjGX;j5 zrzp>|Ud;*&VAU3x#f{CKwY7Vc{%TKKqmB@oTHA9;>?!nvMA;8+Jh=cambHz#J18x~ zs!dF>$*AnsQ{{82r5Aw&^7eRCdvcgyxH?*DV5(I$qXh^zS>us*I66_MbL8y4d3ULj z{S(ipo+T3Ag!+5`NU2sc+@*m{_X|&p#O-SAqF&g_n7ObB82~$p%fXA5GLHMC+#qqL zdt`sJC&6C2)=juQ_!NeD>U8lDVpAOkW*khf7MCcs$A(wiIl#B9HM%~GtQ^}yBPjT@ z+E=|A!Z?A(rwzZ;T}o6pOVqHzTr*i;Wrc%&36kc@jXq~+w8kVrs;%=IFdACoLAcCAmhFNpbP8;s`zG|HC2Gv?I~w4ITy=g$`0qMQdkijLSOtX6xW%Z9Nw<;M- zMN`c7=$QxN00DiSjbVt9Mi6-pjv*j(_8PyV-il8Q-&TwBwH1gz1uoxs6~uU}PrgWB zIAE_I-a1EqlIaGQNbcp@iI8W1sm9fBBNOk(k&iLBe%MCo#?xI$%ZmGA?=)M9D=0t7 zc)Q0LnI)kCy{`jCGy9lYX%mUsDWwsY`;jE(;Us@gmWPqjmXL+Hu#^;k%eT>{nMtzj zsV`Iy6leTA8-PndszF;N^X@CJrTw5IIm!GPeu)H2#FQitR{1p;MasQVAG3*+=9FYK zw*k!HT(YQorfQj+1*mCV458(T5=fH`um$gS38hw(OqVMyunQ;rW5aPbF##A3fGH6h z@W)i9Uff?qz`YbK4c}JzQpuxuE3pcQO)%xBRZp{zJ^-*|oryTxJ-rR+MXJ)!f=+pp z10H|DdGd2exhi+hftcYbM0_}C0ZI-2vh+$fU1acsB-YXid7O|=9L!3e@$H*6?G*Zp z%qFB(sgl=FcC=E4CYGp4CN>=M8#5r!RU!u+FJVlH6=gI5xHVD&k;Ta*M28BsxfMV~ zLz+@6TxnfLhF@5=yQo^1&S}cmTN@m!7*c6z;}~*!hNBjuE>NLVl2EwN!F+)0$R1S! zR|lF%n!9fkZ@gPW|x|B={V6x3`=jS*$Pu0+5OWf?wnIy>Y1MbbGSncpKO0qE(qO=ts z!~@&!N`10S593pVQu4FzpOh!tvg}p%zCU(aV5=~K#bKi zHdJ1>tQSrhW%KOky;iW+O_n;`l9~omqM%sdxdLtI`TrJzN6BQz+7xOl*rM>xVI2~# z)7FJ^Dc{DC<%~VS?@WXzuOG$YPLC;>#vUJ^MmtbSL`_yXtNKa$Hk+l-c!aC7gn(Cg ze?YPYZ(2Jw{SF6MiO5(%_pTo7j@&DHNW`|lD`~{iH+_eSTS&OC*2WTT*a`?|9w1dh zh1nh@$a}T#WE5$7Od~NvSEU)T(W$p$s5fe^GpG+7fdJ9=enRT9$wEk+ZaB>G3$KQO zgq?-rZZnIv!p#>Ty~}c*Lb_jxJg$eGM*XwHUwuQ|o^}b3^T6Bxx{!?va8aC@-xK*H ztJBFvFfsSWu89%@b^l3-B~O!CXs)I6Y}y#0C0U0R0WG zybjroj$io0j}3%P7zADXOwHwafT#uu*zfM!oD$6aJx7+WL%t-@6^rD_a_M?S^>c;z zMK580bZXo1f*L$CuMeM4Mp!;P@}b~$cd(s5*q~FP+NHSq;nw3fbWyH)i2)-;gQl{S zZO!T}A}fC}vUdskGSq&{`oxt~0i?0xhr6I47_tBc`fqaSrMOzR4>0H^;A zF)hX1nfHs)%Zb-(YGX;=#2R6C{BG;k=?FfP?9{_uFLri~-~AJ;jw({4MU7e*d)?P@ zXX*GkNY9ItFjhwgAIWq7Y!ksbMzfqpG)IrqKx9q{zu%Mdl+{Dis#p9q`02pr1LG8R z@As?eG!>IoROgS!@J*to<27coFc1zpkh?w=)h9CbYe%^Q!Ui46Y*HO0mr% zEff-*$ndMNw}H2a5@BsGj5oFfd!T(F&0$<{GO!Qdd?McKkorh=5{EIjDTHU`So>8V zBA-fqVLb2;u7UhDV1xMI?y>fe3~4urv3%PX)lDw+HYa;HFkaLqi4c~VtCm&Ca+9C~ zge+67hp#R9`+Euq59WhHX&7~RlXn=--m8$iZ~~1C8cv^2(qO#X0?vl91gzUKBeR1J z^p4!!&7)3#@@X&2aF2-)1Ffcc^F8r|RtdL2X%HgN&XU-KH2SLCbpw?J5xJ*!F-ypZ zMG%AJ!Pr&}`LW?E!K~=(NJxuSVTRCGJ$2a*Ao=uUDSys!OFYu!Vs2IT;xQ6EubLIl z+?+nMGeQQhh~??0!s4iQ#gm3!BpMpnY?04kK375e((Uc7B3RMj;wE?BCoQGu=UlZt!EZ1Q*auI)dj3Jj{Ujgt zW5hd~-HWBLI_3HuO) zNrb^XzPsTIb=*a69wAAA3J6AAZZ1VsYbIG}a`=d6?PjM)3EPaDpW2YP$|GrBX{q*! z$KBHNif)OKMBCFP5>!1d=DK>8u+Upm-{hj5o|Wn$vh1&K!lVfDB&47lw$tJ?d5|=B z^(_9=(1T3Fte)z^>|3**n}mIX;mMN5v2F#l(q*CvU{Ga`@VMp#%rQkDBy7kYbmb-q z<5!4iuB#Q_lLZ8}h|hPODI^U6`gzLJre9u3k3c#%86IKI*^H-@I48Bi*@avYm4v!n0+v zWu{M{&F8#p9cx+gF0yTB_<2QUrjMPo9*7^-uP#~gGW~y3nfPAoV%amgr>PSyVAd@l)}8#X zR5zV6t*uKJZL}?NYvPVK6J0v4iVpwiN|>+t3aYiZSp;m0!(1`bHO}TEtWR1tY%BPB z(W!0DmXbZAsT$iC13p4f>u*ZAy@JoLAkJhzFf1#4;#1deO8#8d&89}en&z!W&A3++^1(;>0SB1*54d@y&9Pn;^IAf3GiXbfT`_>{R+Xv; zQvgL>+0#8-laO!j#-WB~(I>l0NCMt_;@Gp_f0#^c)t?&#Xh1-7RR0@zPyBz!U#0Av zT?}n({(p?p7!4S2ZBw)#KdCG)uPnZe+U|0{BW!m)9 zi_9$F?m<`2!`JNFv+w8MK_K)qJ^aO@7-Ig>cM4-r0bi=>?B_2mFNJ}aE3<+QCzRr*NA!QjHw# z`1OsvcoD0?%jq{*7b!l|L1+Tw0TTAM4XMq7*ntc-Ived>Sj_ZtS|uVdpfg1_I9knY z2{GM_j5sDC7(W&}#s{jqbybqJWyn?{PW*&cQIU|*v8YGOKKlGl@?c#TCnmnAkAzV- zmK={|1G90zz=YUvC}+fMqts0d4vgA%t6Jhjv?d;(Z}(Ep8fTZfHA9``fdUHkA+z3+ zhh{ohP%Bj?T~{i0sYCQ}uC#5BwN`skI7`|c%kqkyWIQ;!ysvA8H`b-t()n6>GJj6xlYDu~8qX{AFo$Cm3d|XFL=4uvc?Keb zzb0ZmMoXca6Mob>JqkNuoP>B2Z>D`Q(TvrG6m`j}-1rGP!g|qoL=$FVQYxJQjFn33lODt3Wb1j8VR zlR++vIT6^DtYxAv_hxupbLLN3e0%A%a+hWTKDV3!Fjr^cWJ{scsAdfhpI)`Bms^M6 zQG$waKgFr=c|p9Piug=fcJvZ1ThMnNhQvBAg-8~b1?6wL*WyqXhtj^g(Ke}mEfZVM zJuLNTUVh#WsE*a6uqiz`b#9ZYg3+2%=C(6AvZGc=u&<6??!slB1a9K)=VL zY9EL^mfyKnD zSJyYBc_>G;5RRnrNgzJz#Rkn3S1`mZgO`(r5;Hw6MveN(URf_XS-r58Cn80K)ArH4 z#Rrd~LG1W&@ttw85cjp8xV&>$b%nSXH_*W}7Ch2pg$$c0BdEo-HWRTZcxngIBJad> z;C>b{jIXjb_9Jis?NZJsdm^EG}e*pR&DAy0EaSGi3XWTa(>C%tz1n$u?5Fb z1qtl?;_yjYo)(gB^iQq?=jusF%kywm?CJP~zEHi0NbZ);$(H$w(Hy@{i>$wcVRD_X|w-~(0Z9BJyh zhNh;+eQ9BEIs;tPz%jSVnfCP!3L&9YtEP;svoj_bNzeGSQIAjd zBss@A;)R^WAu-37RQrM%{DfBNRx>v!G31Z}8-El9IOJlb_MSoMu2}GDYycNaf>uny z+8xykD-7ONCM!APry_Lw6-yT>5!tR}W;W`C)1>pxSs5o1z#j7%m=&=7O4hz+Lsqm` z*>{+xsabZPr&X=}G@obTb{nPTkccJX8w3CG7X+1+t{JcMabv~UNv+G?txRqXib~c^Mo}`q{$`;EBNJ;#F*{gvS12kV?AZ%O0SFB$^ zn+}!HbmEj}w{Vq(G)OGAzH}R~kS^;(-s&=ectz8vN!_)Yl$$U@HNTI-pV`LSj7Opu zTZ5zZ)-S_{GcEQPIQXLQ#oMS`HPu{`SQiAZ)m1at*Hy%3xma|>o`h%E%8BEbi9p0r zVjcsh<{NBKQ4eKlXU|}@XJ#@uQw*$4BxKn6#W~I4T<^f99~(=}a`&3(ur8R9t+|AQ zWkQx7l}wa48-jO@ft2h+7qn%SJtL%~890FG0s5g*kNbL3I&@brh&f6)TlM`K^(bhr zJWM6N6x3flOw$@|C@kPi7yP&SP?bzP-E|HSXQXG>7gk|R9BTj`e=4de9C6+H7H7n# z#GJeVs1mtHhLDmVO?LkYRQc`DVOJ_vdl8VUihO-j#t=0T3%Fc1f9F73ufJz*adn*p zc%&vi(4NqHu^R>sAT_0EDjVR8bc%wTz#$;%NU-kbDyL_dg0%TFafZwZ?5KZpcuaO54Z9hX zD$u>q!-9`U6-D`E#`W~fIfiIF5_m6{fvM)b1NG3xf4Auw;Go~Fu7cth#DlUn{@~yu z=B;RT*dp?bO}o%4x7k9v{r=Y@^YQ^UUm(Qmliw8brO^=NP+UOohLYiaEB3^DB56&V zK?4jV61B|1Uj_5fBKW;8LdwOFZKWp)g{B%7g1~DgO&N& z#lisxf?R~Z@?3E$Mms$$JK8oe@X`5m98V*aV6Ua}8Xs2#A!{x?IP|N(%nxsH?^c{& z@vY&R1QmQs83BW28qAmJfS7MYi=h(YK??@EhjL-t*5W!p z^gYX!Q6-vBqcv~ruw@oMaU&qp0Fb(dbVzm5xJN%0o_^@fWq$oa3X?9s%+b)x4w-q5Koe(@j6Ez7V@~NRFvd zfBH~)U5!ix3isg`6be__wBJp=1@yfsCMw1C@y+9WYD9_C%{Q~7^0AF2KFryfLlUP# zwrtJEcH)jm48!6tUcxiurAMaiD04C&tPe6DI0#aoqz#Bt0_7_*X*TsF7u*zv(iEfA z;$@?XVu~oX#1YXtceQL{dSneL&*nDug^OW$DSLF0M1Im|sSX8R26&)<0Fbh^*l6!5wfSu8MpMoh=2l z^^0Sr$UpZp*9oqa23fcCfm7`ya2<4wzJ`Axt7e4jJrRFVf?nY~2&tRL* zd;6_njcz01c>$IvN=?K}9ie%Z(BO@JG2J}fT#BJQ+f5LFSgup7i!xWRKw6)iITjZU z%l6hPZia>R!`aZjwCp}I zg)%20;}f+&@t;(%5;RHL>K_&7MH^S+7<|(SZH!u zznW|jz$uA`P9@ZWtJgv$EFp>)K&Gt+4C6#*khZQXS*S~6N%JDT$r`aJDs9|uXWdbg zBwho$phWx}x!qy8&}6y5Vr$G{yGSE*r$^r{}pw zVTZKvikRZ`J_IJrjc=X1uw?estdwm&bEahku&D04HD+0Bm~q#YGS6gp!KLf$A{%Qd z&&yX@Hp>~(wU{|(#U&Bf92+1i&Q*-S+=y=3pSZy$#8Uc$#7oiJUuO{cE6=tsPhwPe| zxQpK>`Dbka`V)$}e6_OXKLB%i76~4N*zA?X+PrhH<&)}prET;kel24kW%+9))G^JI zsq7L{P}^#QsZViX%KgxBvEugr>ZmFqe^oAg?{EI=&_O#e)F3V#rc z8$4}0Zr19qd3tE4#$3_f=Bbx9oV6VO!d3(R===i-7p=Vj`520w0D3W6lQfY48}!D* z&)lZMG;~er2qBoI2gsX+Ts-hnpS~NYRDtPd^FPzn!^&yxRy#CSz(b&E*tL|jIkq|l zf%>)7Dtu>jCf`-7R#*GhGn4FkYf;B$+9IxmqH|lf6$4irg{0ept__%)V*R_OK=T06 zyT_m-o@Kp6U{l5h>W1hGq*X#8*y@<;vsOFqEjTQXFEotR+{3}ODDnj;o0@!bB5x=N z394FojuGOtVKBlVRLtHp%EJv_G5q=AgF)SKyRN5=cGBjDWv4LDn$IL`*=~J7u&Dy5 zrMc83y+w^F&{?X(KOOAl-sWZDb{9X9#jrQtmrEXD?;h-}SYT7yM(X_6qksM=K_a;Z z3u0qT0TtaNvDER_8x*rxXw&C^|h{P1qxK|@pS7vdlZ#P z7PdB7MmC2}%sdzAxt>;WM1s0??`1983O4nFK|hVAbHcZ3x{PzytQLkCVk7hA!Lo` zEJH?4qw|}WH{dc4z%aB=0XqsFW?^p=X}4xnCJXK%c#ItOSjdSO`UXJyuc8bh^Cf}8 z@Ht|vXd^6{Fgai8*tmyRGmD_s_nv~r^Fy7j`Bu`6=G)5H$i7Q7lvQnmea&TGvJp9a|qOrUymZ$6G|Ly z#zOCg++$3iB$!6!>215A4!iryregKuUT344X)jQb3|9qY>c0LO{6Vby05n~VFzd?q zgGZv&FGlkiH*`fTurp>B8v&nSxNz)=5IF$=@rgND4d`!AaaX;_lK~)-U8la_Wa8i?NJC@BURO*sUW)E9oyv3RG^YGfN%BmxzjlT)bp*$<| zX3tt?EAy<&K+bhIuMs-g#=d1}N_?isY)6Ay$mDOKRh z4v1asEGWoAp=srraLW^h&_Uw|6O+r;wns=uwYm=JN4Q!quD8SQRSeEcGh|Eb5Jg8m zOT}u;N|x@aq)=&;wufCc^#)5U^VcZw;d_wwaoh9$p@Xrc{DD6GZUqZ ziC6OT^zSq@-lhbgR8B+e;7_Giv;DK5gn^$bs<6~SUadiosfewWDJu`XsBfOd1|p=q zE>m=zF}!lObA%ePey~gqU8S6h-^J2Y?>7)L2+%8kV}Gp=h`Xm_}rlm)SyUS=`=S7msKu zC|T!gPiI1rWGb1z$Md?0YJQ;%>uPLOXf1Z>N~`~JHJ!^@D5kSXQ4ugnFZ>^`zH8CAiZmp z6Ms|#2gcGsQ{{u7+Nb9sA?U>(0e$5V1|WVwY`Kn)rsnnZ4=1u=7u!4WexZD^IQ1Jk zfF#NLe>W$3m&C^ULjdw+5|)-BSHwpegdyt9NYC{3@QtMfd8GrIWDu`gd0nv-3LpGCh@wgBaG z176tikL!_NXM+Bv#7q^cyn9$XSeZR6#!B4JE@GVH zoobHZN_*RF#@_SVYKkQ_igme-Y5U}cV(hkR#k1c{bQNMji zU7aE`?dHyx=1`kOYZo_8U7?3-7vHOp`Qe%Z*i+FX!s?6huNp0iCEW-Z7E&jRWmUW_ z67j>)Ew!yq)hhG4o?^z}HWH-e=es#xJUhDRc4B51M4~E-l5VZ!&zQq`gWe`?}#b~7w1LH4Xa-UCT5LXkXQWheBa2YJYbyQ zl1pXR%b(KCXMO0OsXgl0P0Og<{(@&z1aokU-Pq`eQq*JYgt8xdFQ6S z6Z3IFSua8W&M#`~*L#r>Jfd6*BzJ?JFdBR#bDv$_0N!_5vnmo@!>vULcDm`MFU823 zpG9pqjqz^FE5zMDoGqhs5OMmC{Y3iVcl>F}5Rs24Y5B^mYQ;1T&ks@pIApHOdrzXF z-SdX}Hf{X;TaSxG_T$0~#RhqKISGKNK47}0*x&nRIPtmdwxc&QT3$8&!3fWu1eZ_P zJveQj^hJL#Sn!*4k`3}(d(aasl&7G0j0-*_2xtAnoX1@9+h zO#c>YQg60Z;o{Bi=3i7S`Ic+ZE>K{(u|#)9y}q*j8uKQ1^>+(BI}m%1v3$=4ojGBc zm+o1*!T&b}-lVvZqIUBc8V}QyFEgm#oyIuC{8WqUNV{Toz`oxhYpP!_p2oHHh5P@iB*NVo~2=GQm+8Yrkm2Xjc_VyHg1c0>+o~@>*Qzo zHVBJS>$$}$_4EniTI;b1WShX<5-p#TPB&!;lP!lBVBbLOOxh6FuYloD%m;n{r|;MU3!q4AVkua~fieeWu2 zQAQ$ue(IklX6+V;F1vCu-&V?I3d42FgWgsb_e^29ol}HYft?{SLf>DrmOp9o!t>I^ zY7fBCk+E8n_|apgM|-;^=#B?6RnFKlN`oR)`e$+;D=yO-(U^jV;rft^G_zl`n7qnM zL z*-Y4Phq+ZI1$j$F-f;`CD#|`-T~OM5Q>x}a>B~Gb3-+9i>Lfr|Ca6S^8g*{*?_5!x zH_N!SoRP=gX1?)q%>QTY!r77e2j9W(I!uAz{T`NdNmPBBUzi2{`XMB^zJGGwFWeA9 z{fk33#*9SO0)DjROug+(M)I-pKA!CX;IY(#gE!UxXVsa)X!UftIN98{pt#4MJHOhY zM$_l}-TJlxY?LS6Nuz1T<44m<4i^8k@D$zuCPrkmz@sdv+{ciyFJG2Zwy&%c7;atIeTdh!a(R^QXnu1Oq1b42*OQFWnyQ zWeQrdvP|w_idy53Wa<{QH^lFmEd+VlJkyiC>6B#s)F;w-{c;aKIm;Kp50HnA-o3lY z9B~F$gJ@yYE#g#X&3ADx&tO+P_@mnQTz9gv30_sTsaGXkfNYXY{$(>*PEN3QL>I!k zp)KibPhrfX3%Z$H6SY`rXGYS~143wZrG2;=FLj50+VM6soI~up_>fU(2Wl@{BRsMi zO%sL3x?2l1cXTF)k&moNsHfQrQ+wu(gBt{sk#CU=UhrvJIncy@tJX5klLjgMn>~h= zg|FR&;@eh|C7`>s_9c~0-{IAPV){l|Ts`i=)AW;d9&KPc3fMeoTS%8@V~D8*h;&(^>yjT84MM}=%#LS7shLAuuj(0VAYoozhWjq z4LEr?wUe2^WGwdTIgWBkDUJa>YP@5d9^Rs$kCXmMRxuF*YMVrn?0NFyPl}>`&dqZb z<5eqR=ZG3>n2{6v6BvJ`YBZeeTtB88TAY(x0a58EWyuf>+^|x8Qa6wA|1Nb_p|nA zWWa}|z8a)--Wj`LqyFk_a3gN2>5{Rl_wbW?#by7&i*^hRknK%jwIH6=dQ8*-_{*x0j^DUfMX0`|K@6C<|1cgZ~D(e5vBFFm;HTZF(!vT8=T$K+|F)x3kqzBV4-=p1V(lzi(s7jdu0>LD#N=$Lk#3HkG!a zIF<7>%B7sRNzJ66KrFV76J<2bdYhxll0y2^_rdG=I%AgW4~)1Nvz=$1UkE^J%BxLo z+lUci`UcU062os*=`-j4IfSQA{w@y|3}Vk?i;&SSdh8n+$iHA#%ERL{;EpXl6u&8@ zzg}?hkEOUOJt?ZL=pWZFJ19mI1@P=$U5*Im1e_8Z${JsM>Ov?nh8Z zP5QvI!{Jy@&BP48%P2{Jr_VgzW;P@7)M9n|lDT|Ep#}7C$&ud&6>C^5ZiwKIg2McPU(4jhM!BD@@L(Gd*Nu$ji(ljZ<{FIeW_1Mmf;76{LU z-ywN~=uNN)Xi6$<12A9y)K%X|(W0p|&>>4OXB?IiYr||WKDOJPxiSe01NSV-h24^L z_>m$;|C+q!Mj**-qQ$L-*++en(g|hw;M!^%_h-iDjFHLo-n3JpB;p?+o2;`*jpvJU zLY^lt)Un4joij^^)O(CKs@7E%*!w>!HA4Q?0}oBJ7Nr8NQ7QmY^4~jvf0-`%waOLn zdNjAPaC0_7c|RVhw)+71NWjRi!y>C+Bl;Z`NiL^zn2*0kmj5gyhCLCxts*cWCdRI| zjsd=sT5BVJc^$GxP~YF$-U{-?kW6r@^vHXB%{CqYzU@1>dzf#3SYedJG-Rm6^RB7s zGM5PR(yKPKR)>?~vpUIeTP7A1sc8-knnJk*9)3t^e%izbdm>Y=W{$wm(cy1RB-19i za#828DMBY+ps#7Y8^6t)=Ea@%Nkt)O6JCx|ybC;Ap}Z@Zw~*}3P>MZLPb4Enxz9Wf zssobT^(R@KuShj8>@!1M7tm|2%-pYYDxz-5`rCbaTCG5{;Uxm z*g=+H1X8{NUvFGzz~wXa%Eo};I;~`37*WrRU&K0dPSB$yk(Z*@K&+mFal^?c zurbqB-+|Kb5|sznT;?Pj!+kgFY1#Dr;_%A(GIQC{3ct|{*Bji%FNa6c-thbpBkA;U zURV!Dr&X{0J}iht#-Qp2=xzuh(fM>zRoiGrYl5ttw2#r34gC41CCOC31m~^UPTK@s z6;A@)7O7_%C)>bnAXerYuAHdE93>j2N}H${zEc6&SbZ|-fiG*-qtGuy-qDelH(|u$ zorf8_T6Zqe#Ub!+e3oSyrskt_HyW_^5lrWt#30l)tHk|j$@YyEkXUOV;6B51L;M@=NIWZXU;GrAa(LGxO%|im%7F<-6N;en0Cr zLH>l*y?pMwt`1*cH~LdBPFY_l;~`N!Clyfr;7w<^X;&(ZiVdF1S5e(+Q%60zgh)s4 zn2yj$+mE=miVERP(g8}G4<85^-5f@qxh2ec?n+$A_`?qN=iyT1?U@t?V6DM~BIlBB z>u~eXm-aE>R0sQy!-I4xtCNi!!qh?R1!kKf6BoH2GG{L4%PAz0{Sh6xpuyI%*~u)s z%rLuFl)uQUCBQAtMyN;%)zFMx4loh7uTfKeB2Xif`lN?2gq6NhWhfz0u5WP9J>=V2 zo{mLtSy&BA!mSzs&CrKWq^y40JF5a&GSXIi2= z{EYb59J4}VwikL4P=>+mc6{($FNE@e=VUwG+KV21;<@lrN`mnz5jYGASyvz7BOG_6(p^eTxD-4O#lROgon;R35=|nj#eHIfJBYPWG>H>`dHKCDZ3`R{-?HO0mE~(5_WYcFmp8sU?wr*UkAQiNDGc6T zA%}GOLXlOWqL?WwfHO8MB#8M8*~Y*gz;1rWWoVSXP&IbKxbQ8+s%4Jnt?kDsq7btI zCDr0PZ)b;B%!lu&CT#RJzm{l{2fq|BcY85`w~3LSK<><@(2EdzFLt9Y_`;WXL6x`0 zDoQ?=?I@Hbr;*VVll1Gmd8*%tiXggMK81a+T(5Gx6;eNb8=uYn z5BG-0g>pP21NPn>$ntBh>`*})Fl|38oC^9Qz>~MAazH%3Q~Qb!ALMf$srexgPZ2@&c~+hxRi1;}+)-06)!#Mq<6GhP z-Q?qmgo${aFBApb5p}$1OJKTClfi8%PpnczyVKkoHw7Ml9e7ikrF0d~UB}i3vizos zXW4DN$SiEV9{faLt5bHy2a>33K%7Td-n5C*N;f&ZqAg#2hIqEb(y<&f4u5BWJ>2^4 z414GosL=Aom#m&=x_v<0-fp1r%oVJ{T-(xnomNJ(Dryv zh?vj+%=II_nV+@NR+(!fZZVM&(W6{6%9cm+o+Z6}KqzLw{(>E86uA1`_K$HqINlb1 zKelh3-jr2I9V?ych`{hta9wQ2c9=MM`2cC{m6^MhlL2{DLv7C^j z$xXBCnDl_;l|bPGMX@*tV)B!c|4oZyftUlP*?$YU9C_eAsuVHJ58?)zpbr30P*C`T z7y#ao`uE-SOG(Pi+`$=e^mle~)pRrdwL5)N;o{gpW21of(QE#U6w%*C~`v-z0QqBML!!5EeYA5IQB0 z^l01c;L6E(iytN!LhL}wfwP7W9PNAkb+)Cst?qg#$n;z41O4&v+8-zPs+XNb-q zIeeBCh#ivnFLUCwfS;p{LC0O7tm+Sf9Jn)~b%uwP{%69;QC)Ok0t%*a5M+=;y8j=v z#!*pp$9@!x;UMIs4~hP#pnfVc!%-D<+wsG@R2+J&%73lK|2G!EQC)O05TCV=&3g)C!lT=czLpZ@Sa%TYuoE?v8T8`V;e$#Zf2_Nj6nvBgh1)2 GZ~q4|mN%#X literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3e8b475d..e18bc253 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Sat Oct 05 01:28:03 GMT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..f3b75f3b 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,69 +15,103 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f9..9b42019c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From 3f3a8a0d0405ffac980384f6f1d68833a61e3c01 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 00:26:09 -0600 Subject: [PATCH 14/49] Start to get the friend profile to work. Profile info and list games currently functional. --- .../com/OxGames/Pluvia/data/OwnedGames.kt | 11 ++ .../com/OxGames/Pluvia/events/SteamEvent.kt | 4 + .../OxGames/Pluvia/service/SteamService.kt | 20 ++- .../Pluvia/service/SteamUnifiedFriends.kt | 45 +++++ .../ui/component/dialog/GamesListDialog.kt | 161 ++++++++++++++++++ .../OxGames/Pluvia/ui/data/FriendsState.kt | 7 +- .../Pluvia/ui/model/FriendsViewModel.kt | 36 +++- .../Pluvia/ui/screen/chat/ChatScreen.kt | 12 +- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 124 ++++++++++---- .../com/OxGames/Pluvia/utils/CoilDecoders.kt | 5 +- .../com/OxGames/Pluvia/utils/SteamUtils.kt | 36 +++- 11 files changed, 408 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt b/app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt new file mode 100644 index 00000000..0be37f30 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt @@ -0,0 +1,11 @@ +package com.OxGames.Pluvia.data + +data class OwnedGames( + val appId: Int = 0, + val name: String = "", + val playtimeTwoWeeks: Int = 0, + val playtimeForever: Int = 0, + val imgIconUrl: String = "", + val sortAs: String? = null, + val rtimeLastPlayed: Int = 0, +) diff --git a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt index 2e1f8fc4..af5e7266 100644 --- a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt +++ b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt @@ -2,6 +2,7 @@ package com.OxGames.Pluvia.events import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.enums.LoginResult +import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback sealed interface SteamEvent : Event { data class Connected(val isAutoLoggingIn: Boolean) : SteamEvent @@ -15,4 +16,7 @@ sealed interface SteamEvent : Event { data object ForceCloseApp : SteamEvent data object Disconnected : SteamEvent data object RemotelyDisconnected : SteamEvent + + // This isn't a SteamEvent, but since its the only one now, it can stay + data class OnProfileInfo(val info: ProfileInfoCallback) : SteamEvent } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 3c6c0b3d..bb3c3785 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -19,6 +19,7 @@ import com.OxGames.Pluvia.data.LibraryAssetsInfo import com.OxGames.Pluvia.data.LibraryCapsuleInfo import com.OxGames.Pluvia.data.LibraryHeroInfo import com.OxGames.Pluvia.data.LibraryLogoInfo +import com.OxGames.Pluvia.data.OwnedGames import com.OxGames.Pluvia.data.PackageInfo import com.OxGames.Pluvia.data.PostSyncInfo import com.OxGames.Pluvia.data.SaveFilePattern @@ -85,6 +86,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamfriends.SteamFriends import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.FriendsListCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.NicknameListCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.PersonaStatesCallback +import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback import `in`.dragonbra.javasteam.steam.handlers.steamgameserver.SteamGameServer import `in`.dragonbra.javasteam.steam.handlers.steammasterserver.SteamMasterServer import `in`.dragonbra.javasteam.steam.handlers.steamscreenshots.SteamScreenshots @@ -1005,7 +1007,16 @@ class SteamService : Service(), IChallengeUrlChanged { } suspend fun getEmoticonList() = withContext(Dispatchers.IO) { - instance?.steamClient?.getHandler()?.getEmoticonList() ?: Timber.w("Failed to get emotes") + // TODO keep callback or handle db operation here with await() + instance?.steamClient!!.getHandler()!!.getEmoticonList() + } + + suspend fun getProfileInfo(friendID: SteamID): ProfileInfoCallback = withContext(Dispatchers.IO) { + instance?._steamFriends!!.requestProfileInfo(friendID).await() + } + + suspend fun getOwnedGames(friendID: Long): List = withContext(Dispatchers.IO) { + instance?._unifiedFriends!!.getOwnedGames(friendID) } } @@ -1085,6 +1096,7 @@ class SteamService : Service(), IChallengeUrlChanged { add(subscribe(NicknameListCallback::class.java, ::onNicknameList)) add(subscribe(FriendsListCallback::class.java, ::onFriendsList)) add(subscribe(EmoticonListCallback::class.java, ::onEmoticonList)) + add(subscribe(ProfileInfoCallback::class.java, ::onProfileInfo)) } } @@ -1408,6 +1420,12 @@ class SteamService : Service(), IChallengeUrlChanged { } } + private fun onProfileInfo(callback: ProfileInfoCallback) { + Timber.i("Getting profile info for ${callback.steamID}") + // TODO: We already wait with the caller, is this needed? + PluviaApp.events.emit(SteamEvent.OnProfileInfo(callback)) + } + @OptIn(ExperimentalStdlibApi::class) private fun onPersonaStateReceived(callback: PersonaStatesCallback) { // Ignore accounts that arent individuals diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index 909fd773..d2ef9ca8 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -2,15 +2,18 @@ package com.OxGames.Pluvia.service import androidx.room.withTransaction import com.OxGames.Pluvia.data.FriendMessage +import com.OxGames.Pluvia.data.OwnedGames import com.OxGames.Pluvia.db.dao.FriendMessagesDao import com.OxGames.Pluvia.db.dao.SteamFriendDao import `in`.dragonbra.javasteam.enums.EChatEntryType import `in`.dragonbra.javasteam.enums.EResult import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient.CChat_RequestFriendPersonaStates_Request import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFriendmessagesSteamclient +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesPlayerSteamclient import `in`.dragonbra.javasteam.rpc.service.Chat import `in`.dragonbra.javasteam.rpc.service.FriendMessages import `in`.dragonbra.javasteam.rpc.service.FriendMessagesClient +import `in`.dragonbra.javasteam.rpc.service.Player import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages import `in`.dragonbra.javasteam.types.SteamID import java.io.Closeable @@ -55,6 +58,8 @@ class SteamUnifiedFriends( private var chat: Chat? = null + private var player: Player? = null + private var friendMessages: FriendMessages? = null // TODO OfflineMessageNotificationCallback ? @@ -63,7 +68,11 @@ class SteamUnifiedFriends( init { unifiedMessages = service.steamClient!!.getHandler() + chat = unifiedMessages!!.createService(Chat::class.java) + + player = unifiedMessages!!.createService(Player::class.java) + friendMessages = unifiedMessages!!.createService(FriendMessages::class.java) service.callbackManager!!.subscribeServiceNotification { @@ -123,6 +132,7 @@ class SteamUnifiedFriends( override fun close() { unifiedMessages = null chat = null + player = null friendMessages = null callbackSubscriptions.forEach { @@ -301,4 +311,39 @@ class SteamUnifiedFriends( // Last part of steamID3 } } + + suspend fun getOwnedGames(steamID: Long): List { + val request = SteammessagesPlayerSteamclient.CPlayer_GetOwnedGames_Request.newBuilder().apply { + steamid = steamID + includePlayedFreeGames = true + includeFreeSub = true + includeAppinfo = true + includeExtendedAppinfo = true + }.build() + + val result = player?.getOwnedGames(request)?.await() + + if (result == null || result.result != EResult.OK) { + Timber.w("Unable to get owned games!") + return emptyList() + } + + val list = result.body.gamesList.map { game -> + OwnedGames( + appId = game.appid, + name = game.name, + playtimeTwoWeeks = game.playtime2Weeks, + playtimeForever = game.playtimeForever, + imgIconUrl = game.imgIconUrl, + sortAs = game.sortAs, + rtimeLastPlayed = game.rtimeLastPlayed, + ) + } + + if (list.size != result.body.gamesCount) { + Timber.w("List was not the same as given") + } + + return list + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt new file mode 100644 index 00000000..90565e0e --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt @@ -0,0 +1,161 @@ +package com.OxGames.Pluvia.ui.component.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.OxGames.Pluvia.Constants +import com.OxGames.Pluvia.data.OwnedGames +import com.OxGames.Pluvia.ui.component.LoadingScreen +import com.OxGames.Pluvia.ui.theme.PluviaTheme +import com.OxGames.Pluvia.ui.util.ListItemImage +import com.OxGames.Pluvia.utils.SteamUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GamesListDialog( + visible: Boolean = true, + list: List, + onDismissRequest: () -> Unit, +) { + if (visible) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + content = { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + title = { Text(text = "Games") }, + navigationIcon = { + IconButton( + onClick = onDismissRequest, + content = { Icon(Icons.Default.Close, null) }, + ) + }, + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding( + top = WindowInsets.statusBars + .asPaddingValues() + .calculateTopPadding() + paddingValues.calculateTopPadding(), + bottom = 24.dp + paddingValues.calculateBottomPadding(), + start = paddingValues.calculateStartPadding(LayoutDirection.Ltr), + end = paddingValues.calculateEndPadding(LayoutDirection.Ltr), + ), + ) { + if (list.isEmpty()) { + item { + LoadingScreen() + } + } + itemsIndexed(items = list, key = { _, item -> item.appId }) { idx, item -> + ListItem( + modifier = Modifier + .animateItem() + .clickable { + // TODO launch to store page? + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + headlineContent = { Text(text = item.name) }, + supportingContent = { + Column { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text(text = "Playtime 2 weeks: ${SteamUtils.formatPlayTime(item.playtimeTwoWeeks)} hrs") + Text(text = "Total playtime: ${SteamUtils.formatPlayTime(item.playtimeForever)} hrs") + Text(text = "Last played: ${SteamUtils.fromSteamTime(item.rtimeLastPlayed)}") + } + } + }, + leadingContent = { + ListItemImage { + // TODO load app icon. + Constants.Persona.MISSING_AVATAR_URL + } + }, + ) + + if (idx < list.lastIndex) { + HorizontalDivider() + } + } + } + } + }, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GamesListDialog() { + PluviaTheme { + GamesListDialog( + visible = true, + list = List(25) { + OwnedGames( + appId = it, + name = "Game Name: $it", + playtimeTwoWeeks = 210 * it, + playtimeForever = 19154 * it, + imgIconUrl = "", + sortAs = "Game Name Alt: $it", + rtimeLastPlayed = 1731210123 * it, + ) + }, + onDismissRequest = { }, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GamesListDialog_EmptyList() { + PluviaTheme { + GamesListDialog( + visible = true, + list = emptyList(), + onDismissRequest = { }, + ) + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt index 7465691c..361fcdd6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt @@ -1,7 +1,12 @@ package com.OxGames.Pluvia.ui.data +import com.OxGames.Pluvia.data.OwnedGames import com.OxGames.Pluvia.data.SteamFriend +import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback data class FriendsState( - val friendsList: Map> = mapOf(), + val friendsList: Map> = emptyMap(), + val profileFriend: SteamFriend? = null, + val profileFriendInfo: ProfileInfoCallback? = null, + val profileFriendGames: List = emptyList(), ) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt index df13c534..2506f44c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt @@ -3,10 +3,13 @@ package com.OxGames.Pluvia.ui.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.OxGames.Pluvia.db.dao.SteamFriendDao +import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.data.FriendsState import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.dragonbra.javasteam.types.SteamID import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,12 +24,43 @@ class FriendsViewModel @Inject constructor( private val _friendsState = MutableStateFlow(FriendsState()) val friendsState: StateFlow = _friendsState.asStateFlow() + private var selectedFriendJob: Job? = null + private var observeFriendListJob: Job? = null + init { observeFriendList() } + override fun onCleared() { + selectedFriendJob?.cancel() + observeFriendListJob?.cancel() + } + + fun observeSelectedFriend(friendID: Long) { + selectedFriendJob?.cancel() + + // Force clear states when this method if is called again. + _friendsState.update { it.copy(profileFriend = null, profileFriendGames = emptyList(), profileFriendInfo = null) } + + viewModelScope.launch { + val resp = SteamService.getProfileInfo(SteamID(friendID)) + _friendsState.update { it.copy(profileFriendInfo = resp) } + } + + viewModelScope.launch { + val resp = SteamService.getOwnedGames(friendID) + _friendsState.update { it.copy(profileFriendGames = resp) } + } + + selectedFriendJob = viewModelScope.launch { + steamFriendDao.findFriend(friendID).collect { friend -> + _friendsState.update { it.copy(profileFriend = friend) } + } + } + } + private fun observeFriendList() { - viewModelScope.launch(Dispatchers.IO) { + observeFriendListJob = viewModelScope.launch(Dispatchers.IO) { steamFriendDao.getAllFriends().collect { friends -> _friendsState.update { currentState -> val sortedList = friends diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 3fd3cd1f..03e07b86 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -63,14 +63,12 @@ import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.ui.util.ListItemImage +import com.OxGames.Pluvia.utils.SteamUtils import com.OxGames.Pluvia.utils.getAvatarURL import com.OxGames.Pluvia.utils.getProfileUrl import dagger.hilt.android.lifecycle.HiltViewModel import `in`.dragonbra.javasteam.enums.EPersonaState import `in`.dragonbra.javasteam.enums.EPersonaStateFlag -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -247,12 +245,6 @@ private fun ChatScreenContent( ) }, ) { paddingValues -> - val sfd = remember { - SimpleDateFormat("MMM d - h:mm a", Locale.getDefault()).apply { - timeZone = TimeZone.getDefault() - } - } - // TODO Typing bar + Send + Emoji selector // TODO scroll to bottom // TODO scroll to bottom if we're ~3 messages slightly scrolled. @@ -266,7 +258,7 @@ private fun ChatScreenContent( items(messages, key = { it.id }) { msg -> ChatBubble( message = msg.message, - timestamp = sfd.format(msg.timestamp * 1000L), + timestamp = SteamUtils.fromSteamTime(msg.timestamp), fromLocal = msg.fromLocal, ) } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 22fe8b17..a0866c17 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -52,9 +53,11 @@ import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -72,6 +75,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowWidthSizeClass import com.OxGames.Pluvia.R import com.OxGames.Pluvia.data.SteamFriend +import com.OxGames.Pluvia.ui.component.LoadingScreen +import com.OxGames.Pluvia.ui.component.dialog.GamesListDialog import com.OxGames.Pluvia.ui.component.topbar.AccountButton import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.data.FriendsState @@ -81,6 +86,10 @@ import com.OxGames.Pluvia.utils.getAvatarURL import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import `in`.dragonbra.javasteam.enums.EPersonaState +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback +import `in`.dragonbra.javasteam.types.SteamID +import java.util.Date @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable @@ -89,14 +98,15 @@ fun FriendsScreen( onSettings: () -> Unit, onLogout: () -> Unit, ) { + val navigator = rememberListDetailPaneScaffoldNavigator() val state by viewModel.friendsState.collectAsStateWithLifecycle() - val navigator = rememberListDetailPaneScaffoldNavigator() val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher FriendsScreenContent( navigator = navigator, state = state, + onFriendClick = viewModel::observeSelectedFriend, onBack = { onBackPressedDispatcher?.onBackPressed() }, onSettings = onSettings, onLogout = onLogout, @@ -106,8 +116,9 @@ fun FriendsScreen( @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) @Composable private fun FriendsScreenContent( - navigator: ThreePaneScaffoldNavigator, + navigator: ThreePaneScaffoldNavigator, state: FriendsState, + onFriendClick: (Long) -> Unit, onBack: () -> Unit, onSettings: () -> Unit, onLogout: () -> Unit, @@ -119,6 +130,16 @@ private fun FriendsScreenContent( navigator.navigateBack(BackNavigationBehavior.PopUntilContentChange) } + var showGamesDialog by remember { mutableStateOf(false) } + + GamesListDialog( + visible = showGamesDialog, + list = state.profileFriendGames, + onDismissRequest = { + showGamesDialog = false + }, + ) + ListDetailPaneScaffold( modifier = Modifier.displayCutoutPadding(), directive = navigator.scaffoldDirective, @@ -144,26 +165,29 @@ private fun FriendsScreenContent( paddingValues = paddingValues, list = state.friendsList, onItemClick = { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) + onFriendClick(it.id) + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) }, ) } } }, detailPane = { - val value = navigator.currentDestination?.content ?: SteamFriend(0) AnimatedPane { Surface { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = { - if (value.id == 0L) { + if (state.profileFriend == null) { DefaultDetailsScreen() } else { ProfileDetailsScreen( - friend = value, + state = state, onBack = onBack, + onShowGames = { + showGamesDialog = true + }, ) } }, @@ -192,21 +216,13 @@ private fun DefaultDetailsScreen() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ProfileDetailsScreen( - friend: SteamFriend, + state: FriendsState, onBack: () -> Unit, + onShowGames: () -> Unit, ) { val scrollState = rememberScrollState() val windowWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass - // TODO placeholders - val steamLevel = 45 - val favoriteBadge = "Years of Service" - val favoriteBadgeIcon = R.drawable.icon_background_gold - - LaunchedEffect(friend) { - // TODO This isn't live data - } - Scaffold( topBar = { // Show Top App Bar when in Compact or Medium screen space. @@ -242,7 +258,7 @@ private fun ProfileDetailsScreen( .clip(CircleShape) .background(Color.DarkGray) .size(92.dp), - imageModel = { friend.avatarHash.getAvatarURL() }, + imageModel = { state.profileFriend!!.avatarHash.getAvatarURL() }, imageOptions = ImageOptions( contentScale = ContentScale.Crop, contentDescription = null, @@ -255,15 +271,15 @@ private fun ProfileDetailsScreen( Spacer(modifier = Modifier.height(24.dp)) Text( - text = friend.nameOrNickname, + text = state.profileFriend!!.nameOrNickname, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineLarge, ) Text( - text = friend.isPlayingGameName, - color = friend.statusColor, + text = state.profileFriend.isPlayingGameName, + color = state.profileFriend.statusColor, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge, @@ -276,6 +292,7 @@ private fun ProfileDetailsScreen( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { + // TODO hoist this val cardItem: @Composable (String, ImageVector, () -> Unit) -> Unit = { text, icon, onClick -> Card( modifier = Modifier @@ -292,7 +309,7 @@ private fun ProfileDetailsScreen( Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -312,13 +329,41 @@ private fun ProfileDetailsScreen( } Spacer(modifier = Modifier.width(16.dp)) cardItem("Games", Icons.Outlined.Games) { - // TODO click + onShowGames() } Spacer(modifier = Modifier.width(16.dp)) cardItem("Options", Icons.Outlined.MoreVert) { // TODO click } } + + Spacer(modifier = Modifier.height(24.dp)) + + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.Start, + ) { + if (state.profileFriendInfo == null) { + LoadingScreen() + } else { + // 'headline' doesn't seem to be used anymore + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { + with(state.profileFriendInfo) { + Text(text = "Name: $realName") + Text(text = "City: $cityName") + Text(text = "State: $stateName") + Text(text = "Country: $countryName") + Text(text = "Since: $timeCreated") + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Summary:\n$summary") + } + } + } + } + } } } } @@ -363,18 +408,11 @@ private fun FriendsListPane( } @OptIn(ExperimentalMaterial3AdaptiveApi::class) -internal class FriendsScreenPreview : PreviewParameterProvider> { - private val friend = SteamFriend( - id = 123L, - nickname = "Pluvia".repeat(3).trimEnd(), - state = EPersonaState.Online, - gameName = "Left 4 Dead 2", - ) - - override val values: Sequence> +internal class FriendsScreenPreview : PreviewParameterProvider> { + override val values: Sequence> get() = sequenceOf( ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail, friend), + ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail), ) } @@ -386,7 +424,7 @@ internal class FriendsScreenPreview : PreviewParameterProvider, + @PreviewParameter(FriendsScreenPreview::class) state: ThreePaneScaffoldDestinationItem, ) { val navigator = rememberListDetailPaneScaffoldNavigator( initialDestinationHistory = listOf(state), @@ -401,7 +439,25 @@ private fun Preview_FriendsScreenContent( "TEST B" to List(3) { SteamFriend(id = it.toLong() + 5) }, "TEST C" to List(3) { SteamFriend(id = it.toLong() + 10) }, ), + profileFriend = SteamFriend( + id = 123L, + nickname = "Pluvia".repeat(3).trimEnd(), + state = EPersonaState.Online, + gameName = "Left 4 Dead 2", + ), + profileFriendInfo = ProfileInfoCallback( + result = EResult.OK, + steamID = SteamID(123L), + timeCreated = Date(9988776655 * 1000L), + realName = "Pluvia", + cityName = "Pluvia Town", + stateName = "Pluviaville", + countryName = "United Pluvia", + headline = "", + summary = "A Fake Summary ːsteamboredː ːsteamthisː", + ), ), + onFriendClick = { }, onBack = { }, onSettings = { }, onLogout = { }, diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt index a7174261..20154154 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt @@ -1,7 +1,7 @@ package com.OxGames.Pluvia.utils import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable +import androidx.core.graphics.drawable.toDrawable import coil.ImageLoader import coil.decode.DecodeResult import coil.decode.Decoder @@ -12,7 +12,6 @@ import com.github.penfeizhou.animation.apng.APNGDrawable import com.github.penfeizhou.animation.apng.decode.APNGParser import com.github.penfeizhou.animation.io.ByteBufferReader import com.github.penfeizhou.animation.io.StreamReader -import com.github.penfeizhou.animation.loader.Loader import java.nio.ByteBuffer import okio.BufferedSource import okio.ByteString.Companion.toByteString @@ -32,7 +31,7 @@ class IconDecoder( val bytes = bufferedSource.readByteArray() val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null DecodeResult( - drawable = BitmapDrawable(options.context.resources, bitmap), + drawable = bitmap.toDrawable(options.context.resources), isSampled = false, ) } catch (e: Exception) { diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt b/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt index 142d09ad..69039747 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt @@ -6,19 +6,49 @@ import android.provider.Settings import com.OxGames.Pluvia.service.SteamService import `in`.dragonbra.javasteam.util.HardwareUtils import java.io.FileOutputStream +import java.math.RoundingMode import java.nio.file.Files import java.nio.file.Paths +import java.text.DecimalFormat +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone import kotlin.io.path.absolutePathString import kotlin.io.path.exists import kotlin.io.path.name object SteamUtils { + + private val sfd by lazy { + SimpleDateFormat("MMM d - h:mm a", Locale.getDefault()).apply { + timeZone = TimeZone.getDefault() + } + } + + private val df by lazy { + DecimalFormat("#.#").apply { + roundingMode = RoundingMode.HALF_UP + } + } + + /** + * Converts steam time to actual time + * @return a string in the 'MMM d - h:mm a' format. + */ + // TODO validate accuracy. + fun fromSteamTime(rtime: Int): String = sfd.format(rtime * 1000L) + + /** + * Converts steam time from the playtime of a friend into an approximate double representing hours. + * @return A double representing how many hours were played, ie: 1.5 hrs + */ + // TODO validate accuracy + fun formatPlayTime(rtime: Int): String = df.format(rtime / 60.0) + /** * Strips non-ASCII characters from String */ - fun removeSpecialChars(s: String): String { - return s.replace(Regex("[^\\u0000-\\u007F]"), "") - } + fun removeSpecialChars(s: String): String = s.replace(Regex("[^\\u0000-\\u007F]"), "") /** * Replaces any existing `steam_api.dll` or `steam_api64.dll` in the app directory From 338d926d8e217de704091de8dafea42de4b456ae Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 00:34:06 -0600 Subject: [PATCH 15/49] Color profile buttons. --- .../com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index a0866c17..c25ff88d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.outlined.Games import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -298,6 +299,9 @@ private fun ProfileDetailsScreen( modifier = Modifier .size(72.dp) .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.onSecondary + ) ) { Column( modifier = Modifier From 0286f5ff1fd7b8195af145062e7c4e6f0e6a1d54 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 10:08:28 -0600 Subject: [PATCH 16/49] Fix account button not updating. --- .../java/com/OxGames/Pluvia/events/SteamEvent.kt | 2 +- .../com/OxGames/Pluvia/service/SteamService.kt | 16 +++++----------- .../Pluvia/ui/component/topbar/AccountButton.kt | 4 +++- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 4 ++-- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt index af5e7266..f66a11df 100644 --- a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt +++ b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt @@ -9,7 +9,7 @@ sealed interface SteamEvent : Event { data class LoggedOut(val username: String?) : SteamEvent data class LogonEnded(val username: String?, val loginResult: LoginResult, val message: String? = null) : SteamEvent data class LogonStarted(val username: String?) : SteamEvent - data class PersonaStateReceived(val persona: SteamFriend?) : SteamEvent + data class PersonaStateReceived(val persona: SteamFriend) : SteamEvent data class QrAuthEnded(val success: Boolean, val message: String? = null) : SteamEvent data class QrChallengeReceived(val challengeUrl: String) : SteamEvent data object AppInfoReceived : SteamEvent diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index bb3c3785..7cc76dd8 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -1472,18 +1472,12 @@ class SteamService : Service(), IChallengeUrlChanged { onlineSessionInstances = callback.onlineSessionInstances, ), ) - } - } - // Send off an event if we change states. - if (callback.friendID == steamClient!!.steamID) { - Timber.d("Emitting PersonaStateReceived") - - dbScope.launch { - val id = callback.friendID.convertToUInt64() - val friend = friendDao.findFriend(id).first() - - PluviaApp.events.emit(SteamEvent.PersonaStateReceived(friend)) + // Send off an event if we change states. + if (callback.friendID == steamClient!!.steamID) { + val loggedInAccount = friendDao.findFriend(id).first() ?: return@withTransaction + PluviaApp.events.emit(SteamEvent.PersonaStateReceived(loggedInAccount)) + } } } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/topbar/AccountButton.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/topbar/AccountButton.kt index dbbbfeb3..22178cab 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/topbar/AccountButton.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/topbar/AccountButton.kt @@ -24,6 +24,7 @@ import com.OxGames.Pluvia.ui.util.ListItemImage import com.OxGames.Pluvia.utils.getAvatarURL import `in`.dragonbra.javasteam.enums.EPersonaState import kotlinx.coroutines.launch +import timber.log.Timber @Composable fun AccountButton( @@ -41,7 +42,8 @@ fun AccountButton( DisposableEffect(true) { val onPersonaStateReceived: (SteamEvent.PersonaStateReceived) -> Unit = { event -> - event.persona?.let { persona = it } + Timber.d("onPersonaStateReceived: ${event.persona.state}") + persona = event.persona } PluviaApp.events.on(onPersonaStateReceived) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index c25ff88d..84debf25 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -300,8 +300,8 @@ private fun ProfileDetailsScreen( .size(72.dp) .clickable(onClick = onClick), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.onSecondary - ) + containerColor = MaterialTheme.colorScheme.onSecondary, + ), ) { Column( modifier = Modifier From 8cf61b71101b3888122f7ec426cafac1b426983b Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 11:51:39 -0600 Subject: [PATCH 17/49] Load game icons and launch to store page when clicked. --- app/src/main/java/com/OxGames/Pluvia/Constants.kt | 5 +++++ .../Pluvia/ui/component/dialog/GamesListDialog.kt | 12 +++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/Constants.kt b/app/src/main/java/com/OxGames/Pluvia/Constants.kt index 2d09547d..7c04c273 100644 --- a/app/src/main/java/com/OxGames/Pluvia/Constants.kt +++ b/app/src/main/java/com/OxGames/Pluvia/Constants.kt @@ -28,6 +28,11 @@ object Constants { const val STICKER_URL = "https://steamcommunity-a.akamaihd.net/economy/sticker/" } + object Library { + const val ICON_URL = "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/" + const val STORE_URL = "https://store.steampowered.com/app/" + } + object Misc { const val TIP_JAR_LINK = "https://buy.stripe.com/5kAaFU1bx2RFeLmbII" const val GITHUB_LINK = "https://github.com/oxters168/Pluvia" diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt index 90565e0e..f803310e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -70,6 +71,8 @@ fun GamesListDialog( ) }, ) { paddingValues -> + val uriHandler = LocalUriHandler.current + LazyColumn( modifier = Modifier .fillMaxSize() @@ -92,7 +95,7 @@ fun GamesListDialog( modifier = Modifier .animateItem() .clickable { - // TODO launch to store page? + uriHandler.openUri(Constants.Library.STORE_URL + item.appId) }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, @@ -108,10 +111,9 @@ fun GamesListDialog( } }, leadingContent = { - ListItemImage { - // TODO load app icon. - Constants.Persona.MISSING_AVATAR_URL - } + ListItemImage( + image = { "${Constants.Library.ICON_URL}${item.appId}/${item.imgIconUrl}.jpg" }, + ) }, ) From 933943520085f490b282173d121879920681bf83 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 16:35:30 -0600 Subject: [PATCH 18/49] Hoist profile action buttons to their own file. Added EmoticonText to render emoticons inline with text. --- .../Pluvia/ui/component/EmoticonText.kt | 116 +++++++ .../ui/screen/friends/FriendProfileButton.kt | 75 +++++ .../Pluvia/ui/screen/friends/FriendsScreen.kt | 296 ++++++++++-------- 3 files changed, 353 insertions(+), 134 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt new file mode 100644 index 00000000..7021a44b --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt @@ -0,0 +1,116 @@ +package com.OxGames.Pluvia.ui.component + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.dp +import com.OxGames.Pluvia.Constants +import com.OxGames.Pluvia.R +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage + +/** + * A wrapper for [Text] that renders emoticons inline with the text. + * Example: + * 1. "Hello world! :steamhappy: + * 2. "Hello World! \\[emoticon]steamhappy[/emoticon] + */ + +private val colonPattern = "\u02D0([^\u02D0]+)\u02D0".toRegex() +private val bbCodePattern = "\\[emoticon\\]([^\\[]+)\\[/emoticon\\]".toRegex() +private val emoticonPattern = "$colonPattern|$bbCodePattern".toRegex() + +@Composable +fun EmoticonText( + modifier: Modifier = Modifier, + text: String, + style: TextStyle = LocalTextStyle.current, +) { + val matches = emoticonPattern.findAll(text).toList() + + val annotatedString = buildAnnotatedString { + var currentIndex = 0 + + matches.forEach { match -> + if (match.range.first > currentIndex) { + val textBefore = text.substring(currentIndex, match.range.first) + append(textBefore) + } + + val emoticonName = match.groupValues + .getOrNull(1) + ?.takeUnless { it.isEmpty() } + ?: match.groupValues.getOrNull(2) + + emoticonName?.let { emoticon -> + appendInlineContent(emoticon, "[emoji]") + } + + currentIndex = match.range.last + 1 + } + + if (currentIndex < text.length) { + append(text.substring(currentIndex)) + } + } + + val inlineContentMap = buildMap { + matches.forEach { match -> + val emoticonName = match.groupValues + .getOrNull(1) + ?.takeUnless { it.isEmpty() } + ?: match.groupValues.getOrNull(2) + + emoticonName?.let { emoticon -> + put( + emoticon, + InlineTextContent( + placeholder = Placeholder( + width = style.fontSize, + height = style.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + CoilImage( + modifier = Modifier.size(style.fontSize.value.dp), + imageModel = { Constants.Chat.EMOTICON_URL + emoticon }, + imageOptions = ImageOptions( + contentDescription = emoticon, + contentScale = ContentScale.Fit, + ), + loading = { + CircularProgressIndicator() + }, + failure = { + Icon(Icons.Filled.QuestionMark, null) + }, + previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), + ) + }, + ), + ) + } + } + } + + Text( + text = annotatedString, + modifier = modifier, + style = style, + inlineContent = inlineContentMap, + ) +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt new file mode 100644 index 00000000..939138b8 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt @@ -0,0 +1,75 @@ +package com.OxGames.Pluvia.ui.screen.friends + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.OxGames.Pluvia.ui.theme.PluviaTheme + +@Composable +fun ProfileButton( + icon: ImageVector, + text: String, + onClick: () -> Unit, +) { + Card( + modifier = Modifier + .size(72.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.onSecondary, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_ProfileButton() { + PluviaTheme { + Surface { + ProfileButton( + icon = Icons.Default.Home, + text = "Button", + onClick = { }, + ) + } + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 84debf25..d8f32a67 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -1,10 +1,10 @@ package com.OxGames.Pluvia.ui.screen.friends import android.content.res.Configuration +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -63,8 +65,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -76,6 +79,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowWidthSizeClass import com.OxGames.Pluvia.R import com.OxGames.Pluvia.data.SteamFriend +import com.OxGames.Pluvia.ui.component.EmoticonText import com.OxGames.Pluvia.ui.component.LoadingScreen import com.OxGames.Pluvia.ui.component.dialog.GamesListDialog import com.OxGames.Pluvia.ui.component.topbar.AccountButton @@ -84,6 +88,7 @@ import com.OxGames.Pluvia.ui.data.FriendsState import com.OxGames.Pluvia.ui.model.FriendsViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.utils.getAvatarURL +import com.OxGames.Pluvia.utils.getProfileUrl import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import `in`.dragonbra.javasteam.enums.EPersonaState @@ -114,7 +119,7 @@ fun FriendsScreen( ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable private fun FriendsScreenContent( navigator: ThreePaneScaffoldNavigator, @@ -124,13 +129,9 @@ private fun FriendsScreenContent( onSettings: () -> Unit, onLogout: () -> Unit, ) { + val listState = rememberLazyListState() // Hoisted high to preserve state val snackbarHost = remember { SnackbarHostState() } - // Pretty much the same as 'NavigableListDetailPaneScaffold' - BackHandler(navigator.canNavigateBack(BackNavigationBehavior.PopUntilContentChange)) { - navigator.navigateBack(BackNavigationBehavior.PopUntilContentChange) - } - var showGamesDialog by remember { mutableStateOf(false) } GamesListDialog( @@ -141,62 +142,129 @@ private fun FriendsScreenContent( }, ) + // Pretty much the same as 'NavigableListDetailPaneScaffold' + BackHandler(navigator.canNavigateBack(BackNavigationBehavior.PopUntilContentChange)) { + navigator.navigateBack(BackNavigationBehavior.PopUntilContentChange) + } + ListDetailPaneScaffold( modifier = Modifier.displayCutoutPadding(), directive = navigator.scaffoldDirective, value = navigator.scaffoldValue, listPane = { AnimatedPane { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHost) }, - topBar = { - CenterAlignedTopAppBar( - title = { Text(text = "Friends") }, - actions = { - AccountButton( - onSettings = onSettings, - onLogout = onLogout, - ) - }, - navigationIcon = { BackButton(onClick = onBack) }, - ) + FriendsListPane( + state = state, + listState = listState, + snackbarHost = snackbarHost, + onBack = onBack, + onFriendClick = { + onFriendClick(it.id) + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) }, - ) { paddingValues -> - FriendsListPane( - paddingValues = paddingValues, - list = state.friendsList, - onItemClick = { - onFriendClick(it.id) - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) - }, - ) - } + onSettings = onSettings, + onLogout = onLogout, + ) } }, detailPane = { AnimatedPane { - Surface { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - content = { - if (state.profileFriend == null) { - DefaultDetailsScreen() - } else { - ProfileDetailsScreen( - state = state, - onBack = onBack, - onShowGames = { - showGamesDialog = true - }, - ) - } + FriendsDetailPane( + state = state, + onBack = onBack, + onShowGames = { + showGamesDialog = true + }, + ) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FriendsListPane( + state: FriendsState, + snackbarHost: SnackbarHostState, + listState: LazyListState, + onBack: () -> Unit, + onFriendClick: (SteamFriend) -> Unit, + onSettings: () -> Unit, + onLogout: () -> Unit, +) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHost) }, + topBar = { + CenterAlignedTopAppBar( + title = { Text(text = "Friends") }, + actions = { + AccountButton( + onSettings = onSettings, + onLogout = onLogout, + ) + }, + navigationIcon = { BackButton(onClick = onBack) }, + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = listState, + contentPadding = PaddingValues(bottom = 72.dp), // Extra space for fab + ) { + state.friendsList.forEach { (key, value) -> + stickyHeader { + StickyHeaderItem( + isCollapsed = false, + header = key, + count = value.size, + onHeaderAction = { + // TODO (Un)Collapse children items. }, ) } + + itemsIndexed(value, key = { _, item -> item.id }) { idx, friend -> + FriendItem( + modifier = Modifier.animateItem(), + friend = friend, + onClick = { onFriendClick(friend) }, + ) + + if (idx < value.lastIndex) { + HorizontalDivider() + } + } } - }, - ) + } + } +} + +@Composable +private fun FriendsDetailPane( + state: FriendsState, + onBack: () -> Unit, + onShowGames: () -> Unit, +) { + Surface { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + content = { + if (state.profileFriend == null) { + DefaultDetailsScreen() + } else { + ProfileDetailsScreen( + state = state, + onBack = onBack, + onShowGames = onShowGames, + ) + } + }, + ) + } } @Composable @@ -243,6 +311,9 @@ private fun ProfileDetailsScreen( } }, ) { paddingValues -> + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + Column( modifier = Modifier .verticalScroll(scrollState) @@ -293,57 +364,48 @@ private fun ProfileDetailsScreen( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - // TODO hoist this - val cardItem: @Composable (String, ImageVector, () -> Unit) -> Unit = { text, icon, onClick -> - Card( - modifier = Modifier - .size(72.dp) - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.onSecondary, - ), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - - cardItem("Chat", Icons.AutoMirrored.Outlined.Chat) { - // TODO click - } + ProfileButton( + icon = Icons.AutoMirrored.Outlined.Chat, + text = "Chat", + onClick = { + // TODO chat + }, + ) Spacer(modifier = Modifier.width(16.dp)) - cardItem("Profile", Icons.Outlined.Person) { - // TODO click - } + ProfileButton( + icon = Icons.Outlined.Person, + text = "Profile", + onClick = { + uriHandler.openUri(state.profileFriend.id.getProfileUrl()) + }, + ) Spacer(modifier = Modifier.width(16.dp)) - cardItem("Games", Icons.Outlined.Games) { - onShowGames() - } + ProfileButton( + icon = Icons.Outlined.Games, + text = "Games", + onClick = onShowGames, + ) Spacer(modifier = Modifier.width(16.dp)) - cardItem("Options", Icons.Outlined.MoreVert) { - // TODO click - } + ProfileButton( + icon = Icons.Outlined.MoreVert, + text = "More", + onClick = { + // TODO more options, such as: + // Friend management: Remove, Block, Unblock + // Notification settings + val msg = "'More' not available yet" + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + }, + ) } Spacer(modifier = Modifier.height(24.dp)) - Card { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.onSecondary, + ), + ) { Column( modifier = Modifier .fillMaxWidth() @@ -362,57 +424,23 @@ private fun ProfileDetailsScreen( Text(text = "Country: $countryName") Text(text = "Since: $timeCreated") Spacer(modifier = Modifier.height(16.dp)) - Text(text = "Summary:\n$summary") + Text(text = "Summary:") + EmoticonText(text = summary) } } } } } - } - } -} - -@Composable -private fun FriendsListPane( - paddingValues: PaddingValues, - list: Map>, - onItemClick: (SteamFriend) -> Unit, -) { - LazyColumn( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize(), - contentPadding = PaddingValues(bottom = 72.dp), // Extra space for fab - ) { - list.forEach { (key, value) -> - stickyHeader { - StickyHeaderItem( - isCollapsed = false, - header = key, - count = value.size, - onHeaderAction = { - // TODO (Un)Collapse children items. - }, - ) - } - - itemsIndexed(value, key = { _, item -> item.id }) { idx, item -> - FriendItem( - modifier = Modifier.animateItem(), - friend = item, - onClick = { onItemClick(item) }, - ) - if (idx < value.lastIndex) { - HorizontalDivider() - } - } + // Bottom scroll padding + Spacer(modifier = Modifier.height(24.dp)) } } } @OptIn(ExperimentalMaterial3AdaptiveApi::class) -internal class FriendsScreenPreview : PreviewParameterProvider> { +internal class FriendsScreenPreview : + PreviewParameterProvider> { override val values: Sequence> get() = sequenceOf( ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), @@ -458,7 +486,7 @@ private fun Preview_FriendsScreenContent( stateName = "Pluviaville", countryName = "United Pluvia", headline = "", - summary = "A Fake Summary ːsteamboredː ːsteamthisː", + summary = "A [emoticon]roar[/emoticon] Fake Summary ːsteamboredː ːsteamthisː", ), ), onFriendClick = { }, From d8ee8a76567e4e649cb15db3508c341f4f8ea455 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 16:51:23 -0600 Subject: [PATCH 19/49] Add some descriptions for a few classes. --- app/src/main/java/com/OxGames/Pluvia/Constants.kt | 2 +- app/src/main/java/com/OxGames/Pluvia/CrashHandler.kt | 5 +++++ app/src/main/java/com/OxGames/Pluvia/PrefManager.kt | 3 ++- app/src/main/java/com/OxGames/Pluvia/ReleaseTree.kt | 9 ++++++--- .../main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt | 10 +++++----- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/Constants.kt b/app/src/main/java/com/OxGames/Pluvia/Constants.kt index 7c04c273..3bd8d3f5 100644 --- a/app/src/main/java/com/OxGames/Pluvia/Constants.kt +++ b/app/src/main/java/com/OxGames/Pluvia/Constants.kt @@ -4,7 +4,7 @@ import androidx.compose.ui.unit.dp /** * Constants values that may be used around the app more than once. - * Constants that are used in composables and or viewmodels should be here too. + * Constants that are used in composables and or view models should be here too. */ object Constants { diff --git a/app/src/main/java/com/OxGames/Pluvia/CrashHandler.kt b/app/src/main/java/com/OxGames/Pluvia/CrashHandler.kt index 9d6de0e7..deae0400 100644 --- a/app/src/main/java/com/OxGames/Pluvia/CrashHandler.kt +++ b/app/src/main/java/com/OxGames/Pluvia/CrashHandler.kt @@ -8,6 +8,11 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +/** + * A local running crash handler. + * Any uncaught exceptions will be saved located locally in a text file, aka: Crash Report. + * File location: //Android/data/com.OxGames.Pluvia/files/crash_logs/ + */ class CrashHandler( private val context: Context, private val defaultHandler: Thread.UncaughtExceptionHandler?, diff --git a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt index dbf74b4d..6a0c3ff0 100644 --- a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt +++ b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt @@ -29,7 +29,8 @@ import kotlinx.coroutines.runBlocking import timber.log.Timber /** - * Kind of ugly, but works to be a universal preference manager. + * A universal Preference Manager that can be used anywhere within Pluvia. + * Note: King of ugly though. */ object PrefManager { diff --git a/app/src/main/java/com/OxGames/Pluvia/ReleaseTree.kt b/app/src/main/java/com/OxGames/Pluvia/ReleaseTree.kt index 9929fa4b..a6761265 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ReleaseTree.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ReleaseTree.kt @@ -3,10 +3,13 @@ package com.OxGames.Pluvia import android.util.Log import timber.log.Timber +/** + * A log manager instance for release mode. + * Debug mode uses [timber.log.Timber.DebugTree] + */ class ReleaseTree : Timber.Tree() { - override fun isLoggable(tag: String?, priority: Int): Boolean { - return priority >= Log.INFO // Ignore Verbose and Debug logs. - } + + override fun isLoggable(tag: String?, priority: Int): Boolean = priority >= Log.INFO // Ignore Verbose and Debug logs. override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { if (!isLoggable(tag, priority)) { diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt index 20154154..6eec9640 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/CoilDecoders.kt @@ -18,8 +18,10 @@ import okio.ByteString.Companion.toByteString import timber.log.Timber /** - * Coil ICO file decoder + * Custom [Decoder]'s for the Coil-Kt image loading library */ + +// .ico file decoder class IconDecoder( private val source: SourceResult, private val options: Options, @@ -67,10 +69,8 @@ class IconDecoder( } } -/** - * Coil APNG file decoder - * From https://github.com/coil-kt/coil/issues/506#issuecomment-952526682 - */ +// .png (Animated) PNG file decoder +// Reference: https://github.com/coil-kt/coil/issues/506#issuecomment-952526682 class AnimatedPngDecoder(private val source: ImageSource) : Decoder { override suspend fun decode(): DecodeResult { val buffer = source.source().squashToDirectByteBuffer() From 0054f454745e8387b9f909bc3e1ad9a659e17966 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 18:45:56 -0600 Subject: [PATCH 20/49] Fix some colors between light dark mode --- .../com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt | 7 +++---- .../Pluvia/ui/screen/friends/FriendProfileButton.kt | 5 ++++- .../com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt | 7 +++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt index 0eece828..8080ec2e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -41,14 +40,14 @@ fun FriendItem( ) { // Can't use CompositionLocal for colors. Instead we can use ListItemDefault.colors() - val isLight = LocalContentColor.current.isLight() + val isLight = MaterialTheme.colorScheme.background.isLight() ListItem( modifier = modifier.clickable { onClick() }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, - headlineColor = if (!isLight) MaterialTheme.colorScheme.onSurface else friend.statusColor, - supportingColor = if (!isLight) MaterialTheme.colorScheme.onSurfaceVariant else friend.statusColor, + headlineColor = if (isLight) MaterialTheme.colorScheme.onSurface else friend.statusColor, + supportingColor = if (isLight) MaterialTheme.colorScheme.onSurfaceVariant else friend.statusColor, ), headlineContent = { Text( diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt index 939138b8..4cac876e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendProfileButton.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.OxGames.Pluvia.ui.theme.PluviaTheme +import com.materialkolor.ktx.isLight @Composable fun ProfileButton( @@ -31,12 +32,13 @@ fun ProfileButton( text: String, onClick: () -> Unit, ) { + val isLight = MaterialTheme.colorScheme.background.isLight() Card( modifier = Modifier .size(72.dp) .clickable(onClick = onClick), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.onSecondary, + containerColor = if (isLight) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSecondary, ), ) { Column( @@ -60,6 +62,7 @@ fun ProfileButton( } } +@Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun Preview_ProfileButton() { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index d8f32a67..a7002e01 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -89,6 +89,7 @@ import com.OxGames.Pluvia.ui.model.FriendsViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.utils.getAvatarURL import com.OxGames.Pluvia.utils.getProfileUrl +import com.materialkolor.ktx.isLight import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import `in`.dragonbra.javasteam.enums.EPersonaState @@ -313,6 +314,7 @@ private fun ProfileDetailsScreen( ) { paddingValues -> val uriHandler = LocalUriHandler.current val context = LocalContext.current + val isLight = MaterialTheme.colorScheme.background.isLight() Column( modifier = Modifier @@ -351,7 +353,7 @@ private fun ProfileDetailsScreen( Text( text = state.profileFriend.isPlayingGameName, - color = state.profileFriend.statusColor, + color = if (isLight) Color.Unspecified else state.profileFriend.statusColor, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge, @@ -403,7 +405,7 @@ private fun ProfileDetailsScreen( Card( colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.onSecondary, + containerColor = if (isLight) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSecondary, ), ) { Column( @@ -449,6 +451,7 @@ internal class FriendsScreenPreview : } @OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, From 1cd6636fba9840a709fdfcbe5692438b63030f5d Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 19:06:23 -0600 Subject: [PATCH 21/49] Implement friends list group collapsing. --- .../OxGames/Pluvia/ui/data/FriendsState.kt | 1 + .../Pluvia/ui/model/FriendsViewModel.kt | 13 +++++++++ .../Pluvia/ui/screen/friends/FriendsScreen.kt | 29 +++++++++++-------- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt index 361fcdd6..a4fc7e0d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt @@ -6,6 +6,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfo data class FriendsState( val friendsList: Map> = emptyMap(), + val collapsedListSections: Set = emptySet(), val profileFriend: SteamFriend? = null, val profileFriendInfo: ProfileInfoCallback? = null, val profileFriendGames: List = emptyList(), diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt index 2506f44c..810a9e87 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt @@ -59,6 +59,19 @@ class FriendsViewModel @Inject constructor( } } + fun onHeaderAction(value: String) { + // TODO save value as preference & restore it + _friendsState.update { currentState -> + val list = currentState.collapsedListSections.toMutableSet() + if (value in list) { + list.remove(value) + } else { + list.add(value) + } + currentState.copy(collapsedListSections = list) + } + } + private fun observeFriendList() { observeFriendListJob = viewModelScope.launch(Dispatchers.IO) { steamFriendDao.getAllFriends().collect { friends -> diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index a7002e01..b8930af7 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -115,6 +115,7 @@ fun FriendsScreen( state = state, onFriendClick = viewModel::observeSelectedFriend, onBack = { onBackPressedDispatcher?.onBackPressed() }, + onHeaderAction = viewModel::onHeaderAction, onSettings = onSettings, onLogout = onLogout, ) @@ -126,6 +127,7 @@ private fun FriendsScreenContent( navigator: ThreePaneScaffoldNavigator, state: FriendsState, onFriendClick: (Long) -> Unit, + onHeaderAction: (String) -> Unit, onBack: () -> Unit, onSettings: () -> Unit, onLogout: () -> Unit, @@ -159,6 +161,7 @@ private fun FriendsScreenContent( listState = listState, snackbarHost = snackbarHost, onBack = onBack, + onHeaderAction = onHeaderAction, onFriendClick = { onFriendClick(it.id) navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) @@ -190,6 +193,7 @@ private fun FriendsListPane( listState: LazyListState, onBack: () -> Unit, onFriendClick: (SteamFriend) -> Unit, + onHeaderAction: (String) -> Unit, onSettings: () -> Unit, onLogout: () -> Unit, ) { @@ -218,24 +222,24 @@ private fun FriendsListPane( state.friendsList.forEach { (key, value) -> stickyHeader { StickyHeaderItem( - isCollapsed = false, + isCollapsed = key in state.collapsedListSections, header = key, count = value.size, - onHeaderAction = { - // TODO (Un)Collapse children items. - }, + onHeaderAction = { onHeaderAction(key) }, ) } - itemsIndexed(value, key = { _, item -> item.id }) { idx, friend -> - FriendItem( - modifier = Modifier.animateItem(), - friend = friend, - onClick = { onFriendClick(friend) }, - ) + if (key !in state.collapsedListSections) { + itemsIndexed(value, key = { _, item -> item.id }) { idx, friend -> + FriendItem( + modifier = Modifier.animateItem(), + friend = friend, + onClick = { onFriendClick(friend) }, + ) - if (idx < value.lastIndex) { - HorizontalDivider() + if (idx < value.lastIndex) { + HorizontalDivider() + } } } } @@ -493,6 +497,7 @@ private fun Preview_FriendsScreenContent( ), ), onFriendClick = { }, + onHeaderAction = { }, onBack = { }, onSettings = { }, onLogout = { }, From 369d913346d4e48583590ef435bc25c8ae00caf3 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 22:31:26 -0600 Subject: [PATCH 22/49] Change EmoticonText to bbCodePattern. Rendering both emoticons and some bb code. --- .../OxGames/Pluvia/ui/component/BBCodeText.kt | 354 ++++++++++++++++++ .../Pluvia/ui/component/EmoticonText.kt | 116 ------ .../Pluvia/ui/screen/friends/FriendItem.kt | 11 +- .../ui/screen/friends/FriendStickyHeader.kt | 3 +- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 18 +- 5 files changed, 374 insertions(+), 128 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt delete mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt new file mode 100644 index 00000000..3b6de2ce --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt @@ -0,0 +1,354 @@ +package com.OxGames.Pluvia.ui.component + +import android.content.res.Configuration +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.OxGames.Pluvia.Constants +import com.OxGames.Pluvia.R +import com.OxGames.Pluvia.ui.theme.PluviaTheme +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage + +/** + * A Custom wrapper that should be able to handle most bb code formating steam acknowledges. + * This also includes emoticon rendering like steam does in chat or in profiles. + * See: https://steamcommunity.com/comment/ForumTopic/formattinghelp + */ + +// private val noParsePattern = "\\[noparse]([^\\[]+)\\[/noparse]".toRegex() +private val colonPattern = "\u02D0([^\u02D0]+)\u02D0".toRegex() +private val emoticonPattern = "\\[emoticon]([^\\[]+)\\[/emoticon]".toRegex() +private val h1Pattern = "\\[h1]([^\\[]+)\\[/h1]".toRegex() +private val h2Pattern = "\\[h2]([^\\[]+)\\[/h2]".toRegex() +private val h3Pattern = "\\[h3]([^\\[]+)\\[/h3]".toRegex() +private val boldPattern = "\\[b]([^\\[]+)\\[/b]".toRegex() +private val italicPattern = "\\[i]([^\\[]+)\\[/i]".toRegex() +private val underlinePattern = "\\[u]([^\\[]+)\\[/u]".toRegex() +private val strikePattern = "\\[strike]([^\\[]+)\\[/strike]".toRegex() +private val spoilerPattern = "\\[spoiler]([^\\[]+)\\[/spoiler]".toRegex() +private val urlPattern = "\\[url=([^]]+)]([^\\[]+)\\[/url]".toRegex() +private val plainUrlPattern = "(https?://\\S+)".toRegex() +private val hrPattern = "\\[hr]([^\\[]*?)\\[/hr]".toRegex() +private val codePattern = "\\[code]([^\\[]*?)\\[/code]".toRegex() +private val quotePattern = "\\[quote=([^]]+)]([^\\[]*?)\\[/quote]".toRegex() + +private val bbCodePattern = ( + "$colonPattern|$emoticonPattern|$h1Pattern|$h2Pattern|$h3Pattern|$boldPattern|$underlinePattern|" + + "$italicPattern|$strikePattern|$spoilerPattern|$urlPattern|$plainUrlPattern|$hrPattern|$codePattern|$quotePattern" + ).toRegex() + +@Composable +fun BBCodeText( + modifier: Modifier = Modifier, + text: String, + style: TextStyle = LocalTextStyle.current, +) { + val revealedSpoilers = remember { mutableStateMapOf() } + val layoutResult = remember { mutableStateOf(null) } + + val matches = bbCodePattern.findAll(text).toList() + + val annotatedString = buildAnnotatedString { + var currentIndex = 0 + + matches.forEach { match -> + if (match.range.first > currentIndex) { + val textBefore = text.substring(currentIndex, match.range.first) + append(textBefore) + } + + when { + match.groups[1] != null || match.groups[2] != null -> { + val emoticonName = match.groupValues + .getOrNull(1) + ?.takeUnless { it.isEmpty() } + ?: match.groupValues.getOrNull(2) + + emoticonName?.let { emoticon -> + appendInlineContent(emoticon, "[emoji]") + } + } + // H1 + match.groups[3] != null -> { + withStyle( + style = SpanStyle( + fontSize = style.fontSize * 1.5f, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift(0.2f), + ), + block = { append(match.groupValues[3]) }, + ) + } + // H2 + match.groups[4] != null -> { + withStyle( + style = SpanStyle( + fontSize = style.fontSize * 1.25f, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift(0.2f), + ), + block = { append(match.groupValues[4]) }, + ) + } + // H3 + match.groups[5] != null -> { + withStyle( + style = SpanStyle( + fontSize = style.fontSize * 1.10f, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift(0.2f), + ), + block = { append(match.groupValues[5]) }, + ) + } + // Bold + match.groups[6] != null -> { + withStyle( + style = SpanStyle(fontWeight = FontWeight.Bold, baselineShift = BaselineShift(0.2f)), + block = { append(match.groupValues[6]) }, + + ) + } + // Underline + match.groups[7] != null -> { + withStyle( + style = SpanStyle(textDecoration = TextDecoration.Underline, baselineShift = BaselineShift(0.2f)), + block = { append(match.groupValues[7]) }, + ) + } + // Italic + match.groups[8] != null -> { + withStyle( + style = SpanStyle(fontStyle = FontStyle.Italic, baselineShift = BaselineShift(0.2f)), + block = { append(match.groupValues[8]) }, + ) + } + // Strike-through + match.groups[9] != null -> { + withStyle( + style = SpanStyle(textDecoration = TextDecoration.LineThrough, baselineShift = BaselineShift(0.2f)), + block = { append(match.groupValues[9]) }, + ) + } + // Spoiler + match.groups[10] != null -> { + val spoilerText = match.groupValues[10] + val spoilerId = "spoiler_${match.range.first}" + + val isRevealed = revealedSpoilers[spoilerId] ?: false + pushStringAnnotation("spoiler", spoilerId) + + withStyle( + style = SpanStyle( + background = if (isRevealed) Color.Unspecified else MaterialTheme.colorScheme.tertiaryContainer, + color = if (isRevealed) Color.Unspecified else MaterialTheme.colorScheme.tertiaryContainer, + baselineShift = BaselineShift(0.2f), + ), + block = { append(spoilerText) }, + ) + pop() + } + // BBcode URL + match.groups[11] != null && match.groups[12] != null -> { + val url = match.groupValues[11] + val linkText = match.groupValues[12].trim() + + pushStringAnnotation("URL", url) + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + baselineShift = BaselineShift(0.2f), + ), + block = { append(linkText) }, + ) + pop() + } + // Plain URL + match.groups[13] != null -> { + val url = match.groupValues[13] + pushStringAnnotation("URL", url) + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + baselineShift = BaselineShift(0.2f), + ), + block = { append(url) }, + ) + pop() + } + // Horizontal Rule + match.groups[14] != null -> { + withStyle( + style = SpanStyle(textDecoration = TextDecoration.LineThrough, baselineShift = BaselineShift(0.2f)), + block = { append(" ") }, + ) + } + // Code + match.groups[15] != null -> { + withStyle( + style = SpanStyle(fontFamily = FontFamily.Monospace, baselineShift = BaselineShift(0.2f)), + block = { append(match.groupValues[15]) }, + ) + } + // Quote + match.groups[16] != null && match.groups[17] != null -> { + withStyle( + style = SpanStyle( + background = MaterialTheme.colorScheme.surfaceVariant, + baselineShift = BaselineShift(0.2f), + ), + ) { + withStyle( + style = SpanStyle(fontStyle = FontStyle.Italic, baselineShift = BaselineShift(0.2f)), + block = { append("Originally posted by ${match.groupValues[16]}:\n") }, + ) + + append(match.groupValues[17]) + } + } + } + + currentIndex = match.range.last + 1 + } + + if (currentIndex < text.length) { + append(text.substring(currentIndex)) + } + } + + // Build inline content map + val inlineContentMap = buildMap { + matches.forEach { match -> + val emoticonName = match.groupValues + .getOrNull(1) + ?.takeUnless { it.isEmpty() } + ?: match.groupValues.getOrNull(2) + + emoticonName?.let { emoticon -> + // Cant idiom this with 'to' + put( + emoticon, + InlineTextContent( + placeholder = Placeholder( + width = style.fontSize, + height = style.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + CoilImage( + modifier = Modifier.size(style.fontSize.value.dp), + imageModel = { Constants.Chat.EMOTICON_URL + emoticon }, + imageOptions = ImageOptions( + contentDescription = emoticon, + contentScale = ContentScale.Fit, + ), + loading = { + CircularProgressIndicator() + }, + failure = { + Icon(Icons.Filled.QuestionMark, null) + }, + previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), + ) + }, + ), + ) + } + } + } + + Text( + text = annotatedString, + modifier = modifier.pointerInput(Unit) { + detectTapGestures { offset -> + val position = annotatedString + .getStringAnnotations("spoiler", start = 0, end = annotatedString.length) + .firstOrNull { annotation -> + val textLayoutResult = layoutResult.value + textLayoutResult?.let { layoutResult -> + val bounds = layoutResult.getBoundingBox(annotation.start) + val expandedBounds = Rect( + bounds.left, + bounds.top, + bounds.left + layoutResult.size.width, + bounds.top + layoutResult.size.height, + ) + expandedBounds.contains(offset) + } ?: false + } + position?.let { annotation -> + revealedSpoilers[annotation.item] = !(revealedSpoilers[annotation.item] ?: false) + } + } + }, + style = style, + inlineContent = inlineContentMap, + onTextLayout = { layoutResult.value = it }, + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview +@Composable +private fun Preview_BBCodeText() { + PluviaTheme { + Surface { + BBCodeText( + text = """ + [h1]Header 1 text[/h1] + [h2]Header 2 text[/h2] + [h3]Header 3 text[/h3] + [b]Bold text [/b] + [u]Underlined text [/u] + [i]Italic text [/i] + [strike]Strikethrough text[/strike] + [spoiler]Spoiler text[/spoiler] + [noparse]Doesn't parse [b]tags[/b][/noparse] + [hr][/hr] + [url=store.steampowered.com] Website link [/url] + https://www.youtube.com/watch?v=tax4e4hBBZc + [quote=author]Quoted text[/quote] + [code]Fixed-width font, preserves spaces[/code] + Some ːsteamhappyː for ːsteamsadː testing. + Hello World! [emoticon]steamhappy[/emoticon] + """.trimIndent(), + ) + } + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt deleted file mode 100644 index 7021a44b..00000000 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/EmoticonText.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.OxGames.Pluvia.ui.component - -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.unit.dp -import com.OxGames.Pluvia.Constants -import com.OxGames.Pluvia.R -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.coil.CoilImage - -/** - * A wrapper for [Text] that renders emoticons inline with the text. - * Example: - * 1. "Hello world! :steamhappy: - * 2. "Hello World! \\[emoticon]steamhappy[/emoticon] - */ - -private val colonPattern = "\u02D0([^\u02D0]+)\u02D0".toRegex() -private val bbCodePattern = "\\[emoticon\\]([^\\[]+)\\[/emoticon\\]".toRegex() -private val emoticonPattern = "$colonPattern|$bbCodePattern".toRegex() - -@Composable -fun EmoticonText( - modifier: Modifier = Modifier, - text: String, - style: TextStyle = LocalTextStyle.current, -) { - val matches = emoticonPattern.findAll(text).toList() - - val annotatedString = buildAnnotatedString { - var currentIndex = 0 - - matches.forEach { match -> - if (match.range.first > currentIndex) { - val textBefore = text.substring(currentIndex, match.range.first) - append(textBefore) - } - - val emoticonName = match.groupValues - .getOrNull(1) - ?.takeUnless { it.isEmpty() } - ?: match.groupValues.getOrNull(2) - - emoticonName?.let { emoticon -> - appendInlineContent(emoticon, "[emoji]") - } - - currentIndex = match.range.last + 1 - } - - if (currentIndex < text.length) { - append(text.substring(currentIndex)) - } - } - - val inlineContentMap = buildMap { - matches.forEach { match -> - val emoticonName = match.groupValues - .getOrNull(1) - ?.takeUnless { it.isEmpty() } - ?: match.groupValues.getOrNull(2) - - emoticonName?.let { emoticon -> - put( - emoticon, - InlineTextContent( - placeholder = Placeholder( - width = style.fontSize, - height = style.fontSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - children = { - CoilImage( - modifier = Modifier.size(style.fontSize.value.dp), - imageModel = { Constants.Chat.EMOTICON_URL + emoticon }, - imageOptions = ImageOptions( - contentDescription = emoticon, - contentScale = ContentScale.Fit, - ), - loading = { - CircularProgressIndicator() - }, - failure = { - Icon(Icons.Filled.QuestionMark, null) - }, - previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), - ) - }, - ), - ) - } - } - } - - Text( - text = annotatedString, - modifier = modifier, - style = style, - inlineContent = inlineContentMap, - ) -} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt index 8080ec2e..2a2455bb 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt @@ -1,7 +1,7 @@ package com.OxGames.Pluvia.ui.screen.friends import android.content.res.Configuration -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -36,14 +36,18 @@ import `in`.dragonbra.javasteam.enums.EPersonaStateFlag fun FriendItem( modifier: Modifier = Modifier, friend: SteamFriend, - onClick: () -> Unit, + onClick: (SteamFriend) -> Unit, + onLongClick: (SteamFriend) -> Unit, ) { // Can't use CompositionLocal for colors. Instead we can use ListItemDefault.colors() val isLight = MaterialTheme.colorScheme.background.isLight() ListItem( - modifier = modifier.clickable { onClick() }, + modifier = modifier.combinedClickable( + onClick = { onClick(friend) }, + onLongClick = { onLongClick(friend) }, + ), colors = ListItemDefaults.colors( containerColor = Color.Transparent, headlineColor = if (isLight) MaterialTheme.colorScheme.onSurface else friend.statusColor, @@ -128,6 +132,7 @@ private fun Preview_FriendItem() { stateFlags = EPersonaStateFlag.from(512.times(index + 1)), ), onClick = { }, + onLongClick = { }, ) } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt index d68bacd3..2a39b005 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt @@ -45,7 +45,8 @@ private fun Preview_StickyHeaderItem() { gameName = "Team Fortess 2", name = "Name The Game", ), - onClick = {}, + onClick = { }, + onLongClick = { }, ) } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index b8930af7..34bfaa52 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -79,7 +79,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowWidthSizeClass import com.OxGames.Pluvia.R import com.OxGames.Pluvia.data.SteamFriend -import com.OxGames.Pluvia.ui.component.EmoticonText +import com.OxGames.Pluvia.ui.component.BBCodeText import com.OxGames.Pluvia.ui.component.LoadingScreen import com.OxGames.Pluvia.ui.component.dialog.GamesListDialog import com.OxGames.Pluvia.ui.component.topbar.AccountButton @@ -234,7 +234,8 @@ private fun FriendsListPane( FriendItem( modifier = Modifier.animateItem(), friend = friend, - onClick = { onFriendClick(friend) }, + onClick = { /* TODO */ }, + onLongClick = { onFriendClick(friend) }, ) if (idx < value.lastIndex) { @@ -424,14 +425,15 @@ private fun ProfileDetailsScreen( // 'headline' doesn't seem to be used anymore CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { with(state.profileFriendInfo) { - Text(text = "Name: $realName") - Text(text = "City: $cityName") - Text(text = "State: $stateName") - Text(text = "Country: $countryName") - Text(text = "Since: $timeCreated") + // Meh... + if (realName.isNotEmpty()) Text(text = "Name: $realName") + if (cityName.isNotEmpty()) Text(text = "City: $cityName") + if (stateName.isNotEmpty()) Text(text = "State: $stateName") + if (stateName.isNotEmpty()) Text(text = "Country: $countryName") + Text(text = "Created: $timeCreated") Spacer(modifier = Modifier.height(16.dp)) Text(text = "Summary:") - EmoticonText(text = summary) + BBCodeText(text = summary) } } } From a4853e4d9147e538cf8d9ea35f1abdd728ea53db Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 22:41:58 -0600 Subject: [PATCH 23/49] Finish up profile screen --- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 34bfaa52..f5cbd04c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -102,8 +102,9 @@ import java.util.Date @Composable fun FriendsScreen( viewModel: FriendsViewModel = hiltViewModel(), - onSettings: () -> Unit, + onChat: (Long) -> Unit, onLogout: () -> Unit, + onSettings: () -> Unit, ) { val navigator = rememberListDetailPaneScaffoldNavigator() val state by viewModel.friendsState.collectAsStateWithLifecycle() @@ -113,11 +114,12 @@ fun FriendsScreen( FriendsScreenContent( navigator = navigator, state = state, - onFriendClick = viewModel::observeSelectedFriend, onBack = { onBackPressedDispatcher?.onBackPressed() }, + onChat = onChat, + onFriendClick = viewModel::observeSelectedFriend, onHeaderAction = viewModel::onHeaderAction, - onSettings = onSettings, onLogout = onLogout, + onSettings = onSettings, ) } @@ -126,11 +128,12 @@ fun FriendsScreen( private fun FriendsScreenContent( navigator: ThreePaneScaffoldNavigator, state: FriendsState, + onBack: () -> Unit, + onChat: (Long) -> Unit, onFriendClick: (Long) -> Unit, onHeaderAction: (String) -> Unit, - onBack: () -> Unit, - onSettings: () -> Unit, onLogout: () -> Unit, + onSettings: () -> Unit, ) { val listState = rememberLazyListState() // Hoisted high to preserve state val snackbarHost = remember { SnackbarHostState() } @@ -161,13 +164,14 @@ private fun FriendsScreenContent( listState = listState, snackbarHost = snackbarHost, onBack = onBack, - onHeaderAction = onHeaderAction, + onChat = onChat, onFriendClick = { onFriendClick(it.id) navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) }, - onSettings = onSettings, + onHeaderAction = onHeaderAction, onLogout = onLogout, + onSettings = onSettings, ) } }, @@ -176,6 +180,7 @@ private fun FriendsScreenContent( FriendsDetailPane( state = state, onBack = onBack, + onChat = onChat, onShowGames = { showGamesDialog = true }, @@ -192,10 +197,11 @@ private fun FriendsListPane( snackbarHost: SnackbarHostState, listState: LazyListState, onBack: () -> Unit, + onChat: (Long) -> Unit, onFriendClick: (SteamFriend) -> Unit, onHeaderAction: (String) -> Unit, - onSettings: () -> Unit, onLogout: () -> Unit, + onSettings: () -> Unit, ) { Scaffold( snackbarHost = { SnackbarHost(snackbarHost) }, @@ -234,7 +240,7 @@ private fun FriendsListPane( FriendItem( modifier = Modifier.animateItem(), friend = friend, - onClick = { /* TODO */ }, + onClick = { onChat(friend.id) }, onLongClick = { onFriendClick(friend) }, ) @@ -252,6 +258,7 @@ private fun FriendsListPane( private fun FriendsDetailPane( state: FriendsState, onBack: () -> Unit, + onChat: (Long) -> Unit, onShowGames: () -> Unit, ) { Surface { @@ -265,6 +272,7 @@ private fun FriendsDetailPane( ProfileDetailsScreen( state = state, onBack = onBack, + onChat = onChat, onShowGames = onShowGames, ) } @@ -293,6 +301,7 @@ private fun DefaultDetailsScreen() { private fun ProfileDetailsScreen( state: FriendsState, onBack: () -> Unit, + onChat: (Long) -> Unit, onShowGames: () -> Unit, ) { val scrollState = rememberScrollState() @@ -374,9 +383,7 @@ private fun ProfileDetailsScreen( ProfileButton( icon = Icons.AutoMirrored.Outlined.Chat, text = "Chat", - onClick = { - // TODO chat - }, + onClick = { onChat(state.profileFriend.id) }, ) Spacer(modifier = Modifier.width(16.dp)) ProfileButton( @@ -503,6 +510,7 @@ private fun Preview_FriendsScreenContent( onBack = { }, onSettings = { }, onLogout = { }, + onChat = { }, ) } } From 91bf7215fd4d993fb395c793b226bd51753dcf1e Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Mon, 27 Jan 2025 23:36:57 -0600 Subject: [PATCH 24/49] Fix library screen issues. 1. Losing scroll position when you navigate away 2. Make a smaller data class for the library with indexing. --- .../com/OxGames/Pluvia/data/LibraryItem.kt | 14 +++++++++ .../OxGames/Pluvia/ui/data/LibraryState.kt | 4 +-- .../Pluvia/ui/model/LibraryViewModel.kt | 21 +++++++++++-- .../OxGames/Pluvia/ui/screen/HomeScreen.kt | 1 + .../Pluvia/ui/screen/library/AppItem.kt | 23 ++++++++++---- .../ui/screen/library/HomeLibraryScreen.kt | 31 +++++++++++++------ 6 files changed, 74 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/data/LibraryItem.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/data/LibraryItem.kt b/app/src/main/java/com/OxGames/Pluvia/data/LibraryItem.kt new file mode 100644 index 00000000..6d7e1aa5 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/data/LibraryItem.kt @@ -0,0 +1,14 @@ +package com.OxGames.Pluvia.data + +/** + * Data class for the Library list + */ +data class LibraryItem( + val index: Int = 0, + val appId: Int = 0, + val name: String = "", + val iconHash: String = "", +) { + val clientIconUrl: String + get() = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/$appId/$iconHash.ico" +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/LibraryState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/LibraryState.kt index 4604095c..49724681 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/LibraryState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/LibraryState.kt @@ -1,11 +1,11 @@ package com.OxGames.Pluvia.ui.data -import com.OxGames.Pluvia.data.AppInfo +import com.OxGames.Pluvia.data.LibraryItem import com.OxGames.Pluvia.ui.enums.FabFilter data class LibraryState( val appInfoSortType: FabFilter = FabFilter.ALPHABETIC, - val appInfoList: List = listOf(), + val appInfoList: List = emptyList(), val isSearching: Boolean = false, val searchQuery: String = "", diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/LibraryViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/LibraryViewModel.kt index 8b424748..f5a37686 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/LibraryViewModel.kt @@ -1,7 +1,12 @@ package com.OxGames.Pluvia.ui.model +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.OxGames.Pluvia.PluviaApp +import com.OxGames.Pluvia.data.LibraryItem import com.OxGames.Pluvia.enums.AppType import com.OxGames.Pluvia.events.SteamEvent import com.OxGames.Pluvia.service.SteamService @@ -18,6 +23,9 @@ class LibraryViewModel : ViewModel() { private val _state = MutableStateFlow(LibraryState()) val state: StateFlow = _state.asStateFlow() + // Keep the library scroll state. This will last longer as the VM will stay alive. + var listState: LazyListState by mutableStateOf(LazyListState(0, 0)) + private val onAppInfoReceived: (SteamEvent.AppInfoReceived) -> Unit = { getAppList() @@ -31,6 +39,7 @@ class LibraryViewModel : ViewModel() { } override fun onCleared() { + Timber.d("onCleared") PluviaApp.events.off(onAppInfoReceived) } @@ -66,10 +75,16 @@ class LibraryViewModel : ViewModel() { it.sortedBy { appInfo -> appInfo.receiveIndex }.reversed() } } + }.mapIndexed { idx, item -> + // Slim down the list with only the necessary values. + LibraryItem( + index = idx, + appId = item.appId, + name = item.name, + iconHash = item.clientIconHash, + ) } - _state.update { currentValue -> - currentValue.copy(appInfoList = list) - } + _state.update { it.copy(appInfoList = list) } } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt index 6daa97e3..b3d20da9 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt @@ -100,6 +100,7 @@ private fun HomeScreenContent( HomeDestination.Friends -> FriendsScreen( onSettings = onSettings, + onChat = { /* TODO */ }, onLogout = onLogout, ) } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/AppItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/AppItem.kt index 2e9d22f0..ca266393 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/AppItem.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/AppItem.kt @@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview -import com.OxGames.Pluvia.data.AppInfo +import com.OxGames.Pluvia.data.LibraryItem import com.OxGames.Pluvia.ui.internal.fakeAppInfo import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.ui.util.ListItemImage @@ -22,7 +22,7 @@ import com.OxGames.Pluvia.ui.util.ListItemImage @Composable fun AppItem( modifier: Modifier = Modifier, - appInfo: AppInfo, + appInfo: LibraryItem, onClick: () -> Unit, ) { ListItem( @@ -43,10 +43,21 @@ private fun Preview_AppItem() { PluviaTheme { Surface { LazyColumn { - items(List(5) { fakeAppInfo(it) }) { - AppItem(appInfo = it, onClick = {}) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } + items( + items = List(5) { idx -> + val item = fakeAppInfo(idx) + LibraryItem( + index = idx, + appId = item.appId, + name = item.name, + iconHash = item.iconHash, + ) + }, + itemContent = { + AppItem(appInfo = it, onClick = {}) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + }, + ) } } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryScreen.kt index 6765b538..009de215 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -57,7 +57,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.OxGames.Pluvia.data.AppInfo +import com.OxGames.Pluvia.data.LibraryItem import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.component.fabmenu.FloatingActionMenu import com.OxGames.Pluvia.ui.component.fabmenu.FloatingActionMenuItem @@ -87,6 +87,7 @@ fun HomeLibraryScreen( LibraryScreenContent( state = state, + listState = viewModel.listState, fabState = fabState, onFabFilter = viewModel::onFabFilter, onIsSearching = viewModel::onIsSearching, @@ -101,6 +102,7 @@ fun HomeLibraryScreen( @Composable private fun LibraryScreenContent( state: LibraryState, + listState: LazyListState, fabState: FloatingActionMenuState, onIsSearching: (Boolean) -> Unit, onSearchQuery: (String) -> Unit, @@ -126,8 +128,10 @@ private fun LibraryScreenContent( Scaffold( snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { + val searchListState = rememberLazyListState() LibrarySearchBar( state = state, + listState = searchListState, onIsSearching = onIsSearching, onSearchQuery = onSearchQuery, onSettings = onSettings, @@ -163,6 +167,7 @@ private fun LibraryScreenContent( ) { paddingValues -> val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() LibraryListPane( + listState = listState, paddingValues = PaddingValues( start = paddingValues.calculateStartPadding(LayoutDirection.Ltr), end = paddingValues.calculateEndPadding(LayoutDirection.Ltr), @@ -204,14 +209,13 @@ private fun LibraryScreenContent( @Composable private fun LibrarySearchBar( state: LibraryState, + listState: LazyListState, onIsSearching: (Boolean) -> Unit, onSearchQuery: (String) -> Unit, onSettings: () -> Unit, onLogout: () -> Unit, onItemClick: (Int) -> Unit, ) { - val listState = rememberLazyListState() - // Debouncer: Scroll to the top after a short amount of time after typing quickly val internalSearchText = remember { MutableStateFlow(state.searchQuery) } LaunchedEffect(Unit) { @@ -289,8 +293,8 @@ private fun LibrarySearchBar( private fun LibraryListPane( paddingValues: PaddingValues, contentPaddingValues: PaddingValues, - listState: LazyListState = rememberLazyListState(), - list: List, + listState: LazyListState, + list: List, onItemClick: (Int) -> Unit, ) { if (list.isEmpty()) { @@ -320,14 +324,14 @@ private fun LibraryListPane( state = listState, contentPadding = contentPaddingValues, ) { - itemsIndexed(list, key = { _, item -> item.appId }) { idx, item -> + items(items = list, key = { it.index }) { item -> AppItem( modifier = Modifier.animateItem(), appInfo = item, onClick = { onItemClick(item.appId) }, ) - if (idx < list.lastIndex) { + if (item.index < list.lastIndex) { HorizontalDivider() } } @@ -378,8 +382,17 @@ private fun LibraryDetailPane( private fun Preview_LibraryScreenContent() { PluviaTheme { LibraryScreenContent( + listState = rememberLazyListState(), state = LibraryState( - appInfoList = List(15) { fakeAppInfo(it).copy(appId = it) }, + appInfoList = List(5) { idx -> + val item = fakeAppInfo(idx) + LibraryItem( + index = idx, + appId = item.appId, + name = item.name, + iconHash = item.iconHash, + ) + }, ), fabState = rememberFloatingActionMenuState(FloatingActionMenuValue.Open), onIsSearching = {}, From db518ff810be5104d4aabee2c5d360cac855dab4 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 28 Jan 2025 00:35:19 -0600 Subject: [PATCH 25/49] Adjust main navigation to get the ChatScreen to launch. --- .../java/com/OxGames/Pluvia/ui/PluviaMain.kt | 44 ++++++++++++++----- .../OxGames/Pluvia/ui/enums/PluviaScreen.kt | 18 ++++---- .../OxGames/Pluvia/ui/model/MainViewModel.kt | 11 ++++- .../OxGames/Pluvia/ui/screen/HomeScreen.kt | 16 ++++--- .../Pluvia/ui/screen/chat/ChatScreen.kt | 34 +++++++++++--- 5 files changed, 93 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt index eef1a76c..517f6c77 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt @@ -19,9 +19,11 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import androidx.navigation.navDeepLink import com.OxGames.Pluvia.BuildConfig import com.OxGames.Pluvia.Constants @@ -42,6 +44,7 @@ import com.OxGames.Pluvia.ui.enums.Orientation import com.OxGames.Pluvia.ui.enums.PluviaScreen import com.OxGames.Pluvia.ui.model.MainViewModel import com.OxGames.Pluvia.ui.screen.HomeScreen +import com.OxGames.Pluvia.ui.screen.chat.ChatScreen import com.OxGames.Pluvia.ui.screen.login.UserLoginScreen import com.OxGames.Pluvia.ui.screen.settings.SettingsScreen import com.OxGames.Pluvia.ui.screen.xserver.XServerScreen @@ -80,7 +83,7 @@ fun PluviaMain( viewModel.uiEvent.collect { event -> when (event) { MainViewModel.MainUiEvent.LaunchApp -> { - navController.navigate(PluviaScreen.XServer.name) + navController.navigate(PluviaScreen.XServer.route) } MainViewModel.MainUiEvent.OnBackPressed -> { @@ -95,7 +98,7 @@ fun PluviaMain( MainViewModel.MainUiEvent.OnLoggedOut -> { // Pop stack and go back to login. navController.popBackStack( - route = PluviaScreen.LoginUser.name, + route = PluviaScreen.LoginUser.route, inclusive = false, saveState = false, ) @@ -106,7 +109,7 @@ fun PluviaMain( LoginResult.Success -> { // TODO: add preference for first screen on login Timber.i("Navigating to library") - navController.navigate(PluviaScreen.Home.name) + navController.navigate(PluviaScreen.Home.route) // If a crash happen, lets not ask for a tip yet. // Instead, ask the user to contribute their issues to be addressed. @@ -191,7 +194,7 @@ fun PluviaMain( // Go to the Home screen if we're already logged in. if (SteamService.isLoggedIn && state.currentScreen == PluviaScreen.LoginUser) { - navController.navigate(PluviaScreen.Home.name) + navController.navigate(PluviaScreen.Home.route) } } @@ -384,15 +387,15 @@ fun PluviaMain( NavHost( modifier = Modifier.fillMaxSize(), navController = navController, - startDestination = PluviaScreen.LoginUser.name, + startDestination = PluviaScreen.LoginUser.route, ) { /** Login **/ - composable(route = PluviaScreen.LoginUser.name) { + composable(route = PluviaScreen.LoginUser.route) { UserLoginScreen() } /** Library, Downloads, Friends **/ composable( - route = PluviaScreen.Home.name, + route = PluviaScreen.Home.route, deepLinks = listOf(navDeepLink { uriPattern = "pluvia://home" }), ) { HomeScreen( @@ -411,8 +414,11 @@ fun PluviaMain( onClickExit = { PluviaApp.events.emit(AndroidEvent.EndProcess) }, + onChat = { + navController.navigate(PluviaScreen.Chat.route(it)) + }, onSettings = { - navController.navigate(PluviaScreen.Settings.name) + navController.navigate(PluviaScreen.Settings.route) }, onLogout = { SteamService.logOut() @@ -421,9 +427,27 @@ fun PluviaMain( } /** Full Screen Chat **/ + composable( + route = "chat/{id}", + arguments = listOf( + navArgument(PluviaScreen.Chat.ARG_ID) { + type = NavType.LongType + }, + ), + ) { + val id = it.arguments?.getLong(PluviaScreen.Chat.ARG_ID) ?: throw RuntimeException("Unable to get ID to chat") + ChatScreen( + friendId = id, + onBack = { + CoroutineScope(Dispatchers.Main).launch { + navController.popBackStack() + } + }, + ) + } /** Game Screen **/ - composable(route = PluviaScreen.XServer.name) { + composable(route = PluviaScreen.XServer.route) { XServerScreen( appId = state.launchedAppId, bootToContainer = state.bootToContainer, @@ -442,7 +466,7 @@ fun PluviaMain( } /** Settings **/ - composable(route = PluviaScreen.Settings.name) { + composable(route = PluviaScreen.Settings.route) { SettingsScreen( appTheme = state.appTheme, paletteStyle = state.paletteStyle, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/enums/PluviaScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/enums/PluviaScreen.kt index 5fa85c03..95c80794 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/enums/PluviaScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/enums/PluviaScreen.kt @@ -1,14 +1,16 @@ package com.OxGames.Pluvia.ui.enums -import androidx.annotation.StringRes -import com.OxGames.Pluvia.R - /** * Destinations for top level screens, excluding home screen destinations. */ -enum class PluviaScreen(@StringRes val title: Int) { - LoginUser(title = R.string.login_user), - Home(title = R.string.home), - XServer(title = R.string.unknown_app), - Settings(title = R.string.settings), +// TODO move out of enums +sealed class PluviaScreen(val route: String) { + data object LoginUser : PluviaScreen("login") + data object Home : PluviaScreen("home") + data object XServer : PluviaScreen("xserver") + data object Settings : PluviaScreen("settings") + data object Chat : PluviaScreen("chat/{id}") { + fun route(id: Long) = "chat/$id" + const val ARG_ID = "id" + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt index 56bf8bf5..c7298e96 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt @@ -148,7 +148,16 @@ class MainViewModel @Inject constructor( } fun setCurrentScreen(currentScreen: String?) { - PluviaScreen.valueOf(currentScreen ?: PluviaScreen.LoginUser.name).also(::setCurrentScreen) + val screen = when (currentScreen) { + "login" -> PluviaScreen.LoginUser + "home" -> PluviaScreen.Home + "xserver" -> PluviaScreen.XServer + "settings" -> PluviaScreen.Settings + "chat/{id}" -> PluviaScreen.Chat + else -> PluviaScreen.LoginUser + } + + setCurrentScreen(screen) } fun setCurrentScreen(value: PluviaScreen) { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt index b3d20da9..9de20025 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt @@ -34,10 +34,11 @@ import com.OxGames.Pluvia.ui.theme.PluviaTheme @Composable fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), - onClickPlay: (Int, Boolean) -> Unit, + onChat: (Long) -> Unit, onClickExit: () -> Unit, - onSettings: () -> Unit, + onClickPlay: (Int, Boolean) -> Unit, onLogout: () -> Unit, + onSettings: () -> Unit, ) { val homeState by viewModel.homeState.collectAsStateWithLifecycle() @@ -68,9 +69,10 @@ fun HomeScreen( HomeScreenContent( destination = homeState.currentDestination, onDestination = viewModel::onDestination, + onChat = onChat, onClickPlay = onClickPlay, - onSettings = onSettings, onLogout = onLogout, + onSettings = onSettings, ) } @@ -78,9 +80,10 @@ fun HomeScreen( private fun HomeScreenContent( destination: HomeDestination, onDestination: (HomeDestination) -> Unit, + onChat: (Long) -> Unit, onClickPlay: (Int, Boolean) -> Unit, - onSettings: () -> Unit, onLogout: () -> Unit, + onSettings: () -> Unit, ) { HomeNavigationWrapperUI( destination = destination, @@ -100,7 +103,7 @@ private fun HomeScreenContent( HomeDestination.Friends -> FriendsScreen( onSettings = onSettings, - onChat = { /* TODO */ }, + onChat = onChat, onLogout = onLogout, ) } @@ -154,9 +157,10 @@ private fun Preview_HomeScreenContent() { HomeScreenContent( destination = destination, onDestination = { destination = it }, + onChat = {}, onClickPlay = { _, _ -> }, - onSettings = {}, onLogout = {}, + onSettings = {}, ) } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 03e07b86..3fe26523 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -70,11 +70,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import `in`.dragonbra.javasteam.enums.EPersonaState import `in`.dragonbra.javasteam.enums.EPersonaStateFlag import javax.inject.Inject +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import timber.log.Timber data class ChatState( val friend: SteamFriend = SteamFriend(0), @@ -92,14 +94,35 @@ class ChatViewModel @Inject constructor( private val _chatState = MutableStateFlow(ChatState()) val chatState: StateFlow = _chatState.asStateFlow() - fun setFriend(id: Long) { + var emoticonJob: Job? = null + var friendJob: Job? = null + var messagesJob: Job? = null + + override fun onCleared() { + super.onCleared() + + Timber.d("onCleared") + + emoticonJob?.cancel() + friendJob?.cancel() + messagesJob?.cancel() + } + + init { viewModelScope.launch { + // Since were initiating a chat, refresh our list of emoticons and stickers SteamService.getEmoticonList() + } + emoticonJob = viewModelScope.launch { emoticonDao.getAll().collect { list -> _chatState.update { it.copy(emoticons = list) } } + } + } + fun setFriend(id: Long) { + friendJob = viewModelScope.launch { friendDao.findFriend(id).collect { friend -> if (friend == null) { throw RuntimeException("Friend is null and cannot proceed") @@ -107,7 +130,9 @@ class ChatViewModel @Inject constructor( _chatState.update { it.copy(friend = friend) } } + } + messagesJob = viewModelScope.launch { messagesDao.getAllMessagesForFriend(id).collect { list -> _chatState.update { it.copy(messages = list) } } @@ -117,13 +142,12 @@ class ChatViewModel @Inject constructor( @Composable fun ChatScreen( - steamFriend: SteamFriend, - viewModel: ChatViewModel = hiltViewModel(key = steamFriend.id.toString()), + friendId: Long, + viewModel: ChatViewModel = hiltViewModel(), onBack: () -> Unit, ) { val state by viewModel.chatState.collectAsStateWithLifecycle() - - viewModel.setFriend(steamFriend.id) + viewModel.setFriend(friendId) ChatScreenContent( steamFriend = state.friend, From cd0a740c6145a124bd620081f45430ebd19c85ed Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 28 Jan 2025 10:57:51 -0600 Subject: [PATCH 26/49] Add option for which screen to launch the app too. --- .../java/com/OxGames/Pluvia/PrefManager.kt | 11 ++ .../ui/component/dialog/StartScreenDialog.kt | 107 ++++++++++++++++++ .../com/OxGames/Pluvia/ui/data/HomeState.kt | 4 +- .../screen/settings/SettingsGroupInterface.kt | 27 +++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt index 6a0c3ff0..db0b7cc8 100644 --- a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt +++ b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt @@ -13,6 +13,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.OxGames.Pluvia.enums.AppTheme import com.OxGames.Pluvia.service.SteamService +import com.OxGames.Pluvia.ui.enums.HomeDestination import com.OxGames.Pluvia.ui.enums.Orientation import com.materialkolor.PaletteStyle import com.winlator.box86_64.Box86_64Preset @@ -372,4 +373,14 @@ object PrefManager { set(value) { setPref(APP_THEME_PALETTE, value.ordinal) } + + private val START_SCREEN = intPreferencesKey("start screen") + var startScreen: HomeDestination + get() { + val value = getPref(START_SCREEN, HomeDestination.Library.ordinal) + return HomeDestination.entries.getOrNull(value) ?: HomeDestination.Library + } + set(value) { + setPref(START_SCREEN, value.ordinal) + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt new file mode 100644 index 00000000..099570df --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt @@ -0,0 +1,107 @@ +package com.OxGames.Pluvia.ui.component.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.OxGames.Pluvia.PrefManager +import com.OxGames.Pluvia.ui.enums.HomeDestination +import com.OxGames.Pluvia.ui.theme.PluviaTheme + +@Composable +fun StartScreenDialog( + openDialog: Boolean, + destination: HomeDestination, + onSelected: (HomeDestination) -> Unit, + onDismiss: () -> Unit, +) { + if (!openDialog) { + return + } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Map, + contentDescription = null, + ) + }, + title = { Text(text = "Start Destination") }, + text = { + Column(modifier = Modifier.selectableGroup()) { + HomeDestination.entries.forEach { entry -> + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = entry == destination, + onClick = { onSelected(entry) }, + role = Role.RadioButton, + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = entry == destination, + onClick = null, + ) + Text( + text = stringResource(id = entry.title), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = "Close") + } + }, + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_StartScreenDialog() { + val content = LocalContext.current + PrefManager.init(content) + + var destination by remember { mutableStateOf(HomeDestination.Downloads) } + + PluviaTheme { + StartScreenDialog( + openDialog = true, + destination = destination, + onSelected = { destination = it }, + onDismiss = { }, + ) + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt index 22afba0b..fd4bd8fa 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt @@ -1,8 +1,10 @@ package com.OxGames.Pluvia.ui.data +import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.ui.enums.HomeDestination data class HomeState( - val currentDestination: HomeDestination = HomeDestination.Library, + // Allow user to set screen for launch. But we also respect pressing back to go to the Library. + val currentDestination: HomeDestination = PrefManager.startScreen, val confirmExit: Boolean = false, ) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt index 487eb929..9a47f3ef 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt @@ -6,9 +6,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.enums.AppTheme import com.OxGames.Pluvia.ui.component.dialog.AppPaletteDialog import com.OxGames.Pluvia.ui.component.dialog.AppThemeDialog +import com.OxGames.Pluvia.ui.component.dialog.StartScreenDialog +import com.OxGames.Pluvia.ui.theme.settingsTileColors import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink import com.materialkolor.PaletteStyle @@ -22,6 +25,8 @@ fun SettingsGroupInterface( ) { var openAppThemeDialog by rememberSaveable { mutableStateOf(false) } var openAppPaletteDialog by rememberSaveable { mutableStateOf(false) } + var openStartScreenDialog by rememberSaveable { mutableStateOf(false) } + var startScreenOption by rememberSaveable { mutableStateOf(PrefManager.startScreen) } AppThemeDialog( openDialog = openAppThemeDialog, @@ -41,8 +46,29 @@ fun SettingsGroupInterface( }, ) + StartScreenDialog( + openDialog = openStartScreenDialog, + destination = startScreenOption, + onSelected = { + startScreenOption = it + PrefManager.startScreen = it + }, + onDismiss = { + openStartScreenDialog = false + }, + ) + SettingsGroup(title = { Text(text = "Interface") }) { SettingsMenuLink( + colors = settingsTileColors(), + title = { Text(text = "Start Destination") }, + subtitle = { Text(text = "Choose between Library, Downloads, Friends") }, + onClick = { + openStartScreenDialog = true + }, + ) + SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "App Theme") }, subtitle = { Text(text = "Choose between Day, Night, or Auto") }, onClick = { @@ -50,6 +76,7 @@ fun SettingsGroupInterface( }, ) SettingsMenuLink( + colors = settingsTileColors(), title = { Text(text = "Palette Style") }, subtitle = { Text(text = "Change the Material Design 3 color palette") }, onClick = { From 552ab09d976d91682b5c998b841f227b8928bf9e Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 28 Jan 2025 12:08:38 -0600 Subject: [PATCH 27/49] Make a universal SingleChoiceDialog to cut down on repetitive composables. --- .../ui/component/dialog/AppPaletteDialog.kt | 114 ------------------ ...ppThemeDialog.kt => SingleChoiceDialog.kt} | 51 ++++---- .../ui/component/dialog/StartScreenDialog.kt | 107 ---------------- .../screen/settings/SettingsGroupInterface.kt | 53 +++++--- 4 files changed, 68 insertions(+), 257 deletions(-) delete mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/AppPaletteDialog.kt rename app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/{AppThemeDialog.kt => SingleChoiceDialog.kt} (70%) delete mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/AppPaletteDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/AppPaletteDialog.kt deleted file mode 100644 index 01dd468c..00000000 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/AppPaletteDialog.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.OxGames.Pluvia.ui.component.dialog - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BrightnessMedium -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.OxGames.Pluvia.PrefManager -import com.OxGames.Pluvia.ui.theme.PluviaTheme -import com.materialkolor.PaletteStyle - -@Composable -fun AppPaletteDialog( - openDialog: Boolean, - paletteStyle: PaletteStyle, - onSelected: (PaletteStyle) -> Unit, - onDismiss: () -> Unit, -) { - if (!openDialog) { - return - } - - val scrollState = rememberScrollState() - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - imageVector = Icons.Default.BrightnessMedium, - contentDescription = null, - ) - }, - title = { Text(text = "Palette Style") }, - text = { - Column( - modifier = Modifier - .selectableGroup() - .verticalScroll(scrollState), - ) { - PaletteStyle.entries.forEach { entry -> - Row( - Modifier - .fillMaxWidth() - .height(56.dp) - .selectable( - selected = entry == paletteStyle, - onClick = { onSelected(entry) }, - role = Role.RadioButton, - ) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = entry == paletteStyle, - onClick = null, - ) - Text( - text = entry.name, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 16.dp), - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(text = "Close") - } - }, - ) -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -private fun Preview_AppPaletteDialog() { - val content = LocalContext.current - PrefManager.init(content) - - var style by remember { mutableStateOf(PaletteStyle.TonalSpot) } - - PluviaTheme { - AppPaletteDialog( - openDialog = true, - paletteStyle = style, - onSelected = { style = it }, - onDismiss = { }, - ) - } -} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/AppThemeDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/SingleChoiceDialog.kt similarity index 70% rename from app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/AppThemeDialog.kt rename to app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/SingleChoiceDialog.kt index 0c5125d5..d7405de1 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/AppThemeDialog.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/SingleChoiceDialog.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview @@ -32,10 +33,14 @@ import com.OxGames.Pluvia.enums.AppTheme import com.OxGames.Pluvia.ui.theme.PluviaTheme @Composable -fun AppThemeDialog( +fun SingleChoiceDialog( openDialog: Boolean, - appTheme: AppTheme, - onSelected: (AppTheme) -> Unit, + icon: ImageVector? = null, + iconDescription: String? = null, + title: String, + items: List, + currentItem: Int, + onSelected: (Int) -> Unit, onDismiss: () -> Unit, ) { if (!openDialog) { @@ -45,33 +50,32 @@ fun AppThemeDialog( AlertDialog( onDismissRequest = onDismiss, icon = { - Icon( - imageVector = Icons.Default.BrightnessMedium, - contentDescription = null, - ) + icon?.let { + Icon(imageVector = it, contentDescription = iconDescription) + } }, - title = { Text(text = "App Theme") }, + title = { Text(text = title) }, text = { Column(modifier = Modifier.selectableGroup()) { - AppTheme.entries.forEach { entry -> + items.forEachIndexed { index, entry -> Row( Modifier .fillMaxWidth() .height(56.dp) .selectable( - selected = entry == appTheme, - onClick = { onSelected(entry) }, + selected = index == currentItem, + onClick = { onSelected(index) }, role = Role.RadioButton, ) .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { RadioButton( - selected = entry == appTheme, + selected = index == currentItem, onClick = null, ) Text( - text = entry.text, + text = entry, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 16.dp), ) @@ -80,26 +84,31 @@ fun AppThemeDialog( } }, confirmButton = { - TextButton(onClick = onDismiss) { - Text(text = "Close") - } + TextButton( + onClick = onDismiss, + content = { Text(text = "Close") }, + ) }, ) } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable -private fun Preview_AppThemeDialog() { +private fun Preview_SingleChoiceDialog() { val content = LocalContext.current PrefManager.init(content) - var theme by remember { mutableStateOf(AppTheme.DAY) } + val list = remember { AppTheme.entries } + var theme by remember { mutableStateOf(AppTheme.NIGHT) } PluviaTheme { - AppThemeDialog( + SingleChoiceDialog( openDialog = true, - appTheme = theme, - onSelected = { theme = it }, + items = list.map { it.text }, + icon = Icons.Default.BrightnessMedium, + title = "App Theme", + currentItem = theme.ordinal, + onSelected = { theme = list[it] }, onDismiss = { }, ) } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt deleted file mode 100644 index 099570df..00000000 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/StartScreenDialog.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.OxGames.Pluvia.ui.component.dialog - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Map -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.OxGames.Pluvia.PrefManager -import com.OxGames.Pluvia.ui.enums.HomeDestination -import com.OxGames.Pluvia.ui.theme.PluviaTheme - -@Composable -fun StartScreenDialog( - openDialog: Boolean, - destination: HomeDestination, - onSelected: (HomeDestination) -> Unit, - onDismiss: () -> Unit, -) { - if (!openDialog) { - return - } - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - imageVector = Icons.Default.Map, - contentDescription = null, - ) - }, - title = { Text(text = "Start Destination") }, - text = { - Column(modifier = Modifier.selectableGroup()) { - HomeDestination.entries.forEach { entry -> - Row( - Modifier - .fillMaxWidth() - .height(56.dp) - .selectable( - selected = entry == destination, - onClick = { onSelected(entry) }, - role = Role.RadioButton, - ) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = entry == destination, - onClick = null, - ) - Text( - text = stringResource(id = entry.title), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 16.dp), - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(text = "Close") - } - }, - ) -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -private fun Preview_StartScreenDialog() { - val content = LocalContext.current - PrefManager.init(content) - - var destination by remember { mutableStateOf(HomeDestination.Downloads) } - - PluviaTheme { - StartScreenDialog( - openDialog = true, - destination = destination, - onSelected = { destination = it }, - onDismiss = { }, - ) - } -} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt index 9a47f3ef..ce0d7431 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/settings/SettingsGroupInterface.kt @@ -1,16 +1,20 @@ package com.OxGames.Pluvia.ui.screen.settings +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrightnessMedium +import androidx.compose.material.icons.filled.ColorLens +import androidx.compose.material.icons.filled.Map import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.enums.AppTheme -import com.OxGames.Pluvia.ui.component.dialog.AppPaletteDialog -import com.OxGames.Pluvia.ui.component.dialog.AppThemeDialog -import com.OxGames.Pluvia.ui.component.dialog.StartScreenDialog +import com.OxGames.Pluvia.ui.component.dialog.SingleChoiceDialog +import com.OxGames.Pluvia.ui.enums.HomeDestination import com.OxGames.Pluvia.ui.theme.settingsTileColors import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink @@ -23,36 +27,55 @@ fun SettingsGroupInterface( onAppTheme: (AppTheme) -> Unit, onPaletteStyle: (PaletteStyle) -> Unit, ) { + val context = LocalContext.current + var openAppThemeDialog by rememberSaveable { mutableStateOf(false) } var openAppPaletteDialog by rememberSaveable { mutableStateOf(false) } + var openStartScreenDialog by rememberSaveable { mutableStateOf(false) } - var startScreenOption by rememberSaveable { mutableStateOf(PrefManager.startScreen) } + var startScreenOption by rememberSaveable(openStartScreenDialog) { mutableStateOf(PrefManager.startScreen) } - AppThemeDialog( + SingleChoiceDialog( openDialog = openAppThemeDialog, - appTheme = appTheme, - onSelected = onAppTheme, + icon = Icons.Default.BrightnessMedium, + title = "App Theme", + items = AppTheme.entries.map { it.text }, + onSelected = { + val entry = AppTheme.entries[it] + onAppTheme(entry) + }, + currentItem = appTheme.ordinal, onDismiss = { openAppThemeDialog = false }, ) - AppPaletteDialog( + SingleChoiceDialog( openDialog = openAppPaletteDialog, - paletteStyle = paletteStyle, - onSelected = onPaletteStyle, + icon = Icons.Default.ColorLens, + title = "Palette Style", + items = PaletteStyle.entries.map { it.name }, + onSelected = { + val entry = PaletteStyle.entries[it] + onPaletteStyle(entry) + }, + currentItem = paletteStyle.ordinal, onDismiss = { openAppPaletteDialog = false }, ) - StartScreenDialog( + SingleChoiceDialog( openDialog = openStartScreenDialog, - destination = startScreenOption, + icon = Icons.Default.Map, + title = "Start Screen", + items = HomeDestination.entries.map { context.getString(it.title) }, onSelected = { - startScreenOption = it - PrefManager.startScreen = it + val entry = HomeDestination.entries[it] + startScreenOption = entry + PrefManager.startScreen = entry }, + currentItem = startScreenOption.ordinal, onDismiss = { openStartScreenDialog = false }, @@ -62,7 +85,7 @@ fun SettingsGroupInterface( SettingsMenuLink( colors = settingsTileColors(), title = { Text(text = "Start Destination") }, - subtitle = { Text(text = "Choose between Library, Downloads, Friends") }, + subtitle = { Text(text = "Choose between Library, Downloads, or Friends") }, onClick = { openStartScreenDialog = true }, From 22e92669001543f765ec4f9c28dcb233b217ad6d Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 28 Jan 2025 15:04:31 -0600 Subject: [PATCH 28/49] Assemble more functionality into chat. --- .../com/OxGames/Pluvia/data/FriendMessage.kt | 1 - .../Pluvia/db/dao/FriendMessagesDao.kt | 40 ++++++ .../OxGames/Pluvia/service/SteamService.kt | 9 +- .../Pluvia/service/SteamUnifiedFriends.kt | 47 ++++--- .../OxGames/Pluvia/ui/component/BBCodeText.kt | 129 +++++++++++++----- .../Pluvia/ui/screen/chat/ChatMessageItem.kt | 3 +- .../Pluvia/ui/screen/chat/ChatScreen.kt | 83 +++++++---- 7 files changed, 229 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt b/app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt index 8817f847..bdb19c14 100644 --- a/app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt +++ b/app/src/main/java/com/OxGames/Pluvia/data/FriendMessage.kt @@ -11,6 +11,5 @@ data class FriendMessage( @ColumnInfo(name = "steam_id_friend") val steamIDFriend: Long, @ColumnInfo(name = "from_local") val fromLocal: Boolean, @ColumnInfo(name = "message") val message: String, - @ColumnInfo(name = "low_priority") val lowPriority: Boolean, @ColumnInfo(name = "timestamp") val timestamp: Int, ) diff --git a/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt b/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt index d0759687..31261535 100644 --- a/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt +++ b/app/src/main/java/com/OxGames/Pluvia/db/dao/FriendMessagesDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query +import androidx.room.Transaction import androidx.room.Update import com.OxGames.Pluvia.data.FriendMessage import `in`.dragonbra.javasteam.types.SteamID @@ -34,4 +35,43 @@ interface FriendMessagesDao { @Query("SELECT COUNT(*) FROM chat_message WHERE steam_id_friend = :steamId") fun getMessageCountForFriend(steamId: SteamID): Flow + + @Query("SELECT EXISTS(SELECT 1 FROM chat_message WHERE steam_id_friend = :steamId AND timestamp = :timestamp AND message = :message)") + suspend fun messageExists(steamId: Long, timestamp: Int, message: String): Boolean + + @Transaction + suspend fun insertMessageIfNotExists(message: FriendMessage): Boolean { + val exists = messageExists( + steamId = message.steamIDFriend, + timestamp = message.timestamp, + message = message.message, + ) + + if (!exists) { + insertMessage(message) + return true + } + + return false + } + + @Transaction + suspend fun insertMessagesIfNotExist(messages: List): List { + val insertedMessages = mutableListOf() + + messages.forEach { message -> + val exists = messageExists( + steamId = message.steamIDFriend, + timestamp = message.timestamp, + message = message.message, + ) + + if (!exists) { + insertMessage(message) + insertedMessages.add(message) + } + } + + return insertedMessages + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 7cc76dd8..7e5cc098 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -1007,7 +1007,6 @@ class SteamService : Service(), IChallengeUrlChanged { } suspend fun getEmoticonList() = withContext(Dispatchers.IO) { - // TODO keep callback or handle db operation here with await() instance?.steamClient!!.getHandler()!!.getEmoticonList() } @@ -1018,6 +1017,14 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun getOwnedGames(friendID: Long): List = withContext(Dispatchers.IO) { instance?._unifiedFriends!!.getOwnedGames(friendID) } + + suspend fun getRecentMessages(friendID: Long) = withContext(Dispatchers.IO) { + instance?._unifiedFriends!!.getRecentMessages(friendID) + } + + suspend fun ackMessage(friendID: Long) = withContext(Dispatchers.IO) { + instance?._unifiedFriends!!.ackMessage(friendID) + } } override fun onCreate() { diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index d2ef9ca8..843619ed 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -116,7 +116,7 @@ class SteamUnifiedFriends( fromLocal = false, message = it.body.message, timestamp = it.body.rtime32ServerTimestamp, - lowPriority = it.body.lowPriority, + // lowPriority = it.body.lowPriority, ) messagesDao.insertMessage(chatMsg) @@ -148,12 +148,14 @@ class SteamUnifiedFriends( chat?.requestFriendPersonaStates(request) } - suspend fun getRecentMessages(friendID: SteamID) { - Timber.i("Getting Recent messages for: ${friendID.convertToUInt64()}") + suspend fun getRecentMessages(friendID: Long) { + Timber.i("Getting Recent messages for: $friendID") + + val userSteamID = SteamService.userSteamId!! val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_GetRecentMessages_Request.newBuilder().apply { - steamid1 = SteamService.userSteamId!!.convertToUInt64() // You - steamid2 = friendID.convertToUInt64() // Friend + steamid1 = userSteamID.convertToUInt64() // You + steamid2 = friendID // Friend // The rest here and below is what steam has looking at NHA2 count = 50 rtime32StartTime = 0 @@ -166,25 +168,28 @@ class SteamUnifiedFriends( val response = friendMessages!!.getRecentMessages(request).await() if (response.result != EResult.OK) { - Timber.w("Failed to get message history for friend: ${friendID.convertToUInt64()}, ${response.result}") + Timber.w("Failed to get message history for friend: $friendID, ${response.result}") return } // TODO: Insert new messages into database // TODO: Do not dupe messages - response.body.messagesList.forEach { message -> - // message.accountid - // message.timestamp - // message.message - // message.ordinal - // message.reactionsList.forEach { reaction -> - // reaction.reaction - // reaction.reactionType - // reaction.reactionBytes - // reaction.reactorsList - // reaction.reactorsCount - // } + // TODO: reactions + val regex = "\\[U:\\d+:(\\d+)]".toRegex() + val userSteamId3 = regex.find(userSteamID.render())!!.groupValues[1].toInt() + val messages = response.body.messagesList.map { message -> + FriendMessage( + steamIDFriend = friendID, + fromLocal = userSteamId3 == message.accountid, + message = message.message, + timestamp = message.timestamp, + ) } + + service.db.withTransaction { + messagesDao.insertMessagesIfNotExist(messages) + } + Timber.i("More available: ${response.body.moreAvailable}") } @@ -239,10 +244,10 @@ class SteamUnifiedFriends( // Once chat notifications are implemented, we should clear it here as well. } - fun ackMessage(friendID: SteamID) { - Timber.d("Ack-ing message for friend: ${friendID.convertToUInt64()}") + fun ackMessage(friendID: Long) { + Timber.d("Ack-ing message for friend: $friendID") val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.newBuilder().apply { - steamidPartner = friendID.convertToUInt64() + steamidPartner = friendID timestamp = System.currentTimeMillis().div(1000).toInt() }.build() diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt index 3b6de2ce..d2bebdd0 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt @@ -2,6 +2,9 @@ package com.OxGames.Pluvia.ui.component import android.content.res.Configuration import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -37,6 +40,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.OxGames.Pluvia.Constants import com.OxGames.Pluvia.R import com.OxGames.Pluvia.ui.theme.PluviaTheme @@ -65,16 +69,19 @@ private val plainUrlPattern = "(https?://\\S+)".toRegex() private val hrPattern = "\\[hr]([^\\[]*?)\\[/hr]".toRegex() private val codePattern = "\\[code]([^\\[]*?)\\[/code]".toRegex() private val quotePattern = "\\[quote=([^]]+)]([^\\[]*?)\\[/quote]".toRegex() +private val stickerPattern = "\\[sticker type=\"(.*?)\".*?]\\[/sticker]".toRegex() private val bbCodePattern = ( "$colonPattern|$emoticonPattern|$h1Pattern|$h2Pattern|$h3Pattern|$boldPattern|$underlinePattern|" + - "$italicPattern|$strikePattern|$spoilerPattern|$urlPattern|$plainUrlPattern|$hrPattern|$codePattern|$quotePattern" + "$italicPattern|$strikePattern|$spoilerPattern|$urlPattern|$plainUrlPattern|$hrPattern|$codePattern|" + + "$quotePattern|$stickerPattern" ).toRegex() @Composable fun BBCodeText( modifier: Modifier = Modifier, text: String, + color: Color = Color.Unspecified, style: TextStyle = LocalTextStyle.current, ) { val revealedSpoilers = remember { mutableStateMapOf() } @@ -242,6 +249,12 @@ fun BBCodeText( append(match.groupValues[17]) } } + // Sticker + match.groups[18] != null -> { + val stickerType = match.groupValues[18] + val stickerId = "sticker_$stickerType" + appendInlineContent(stickerId, "[sticker]") + } } currentIndex = match.range.last + 1 @@ -253,48 +266,86 @@ fun BBCodeText( } // Build inline content map +// Build inline content map val inlineContentMap = buildMap { matches.forEach { match -> - val emoticonName = match.groupValues - .getOrNull(1) - ?.takeUnless { it.isEmpty() } - ?: match.groupValues.getOrNull(2) + when { + // Handle emoticons + match.groups[1] != null || match.groups[2] != null -> { + val emoticonName = match.groupValues + .getOrNull(1) + ?.takeUnless { it.isEmpty() } + ?: match.groupValues.getOrNull(2) - emoticonName?.let { emoticon -> - // Cant idiom this with 'to' - put( - emoticon, - InlineTextContent( - placeholder = Placeholder( - width = style.fontSize, - height = style.fontSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - children = { - CoilImage( - modifier = Modifier.size(style.fontSize.value.dp), - imageModel = { Constants.Chat.EMOTICON_URL + emoticon }, - imageOptions = ImageOptions( - contentDescription = emoticon, - contentScale = ContentScale.Fit, + emoticonName?.let { emoticon -> + put( + emoticon, + InlineTextContent( + placeholder = Placeholder( + width = style.fontSize, + height = style.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, ), - loading = { - CircularProgressIndicator() + children = { + CoilImage( + modifier = Modifier.size(style.fontSize.value.dp), + imageModel = { Constants.Chat.EMOTICON_URL + emoticon }, + imageOptions = ImageOptions( + contentDescription = emoticon, + contentScale = ContentScale.Fit, + ), + loading = { + CircularProgressIndicator() + }, + failure = { + Icon(Icons.Filled.QuestionMark, null) + }, + previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), + ) }, - failure = { - Icon(Icons.Filled.QuestionMark, null) - }, - previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), - ) - }, - ), - ) + ), + ) + } + } + // Handle stickers + match.groups[18] != null -> { + val stickerType = match.groupValues[18] + val stickerId = "sticker_$stickerType" + put( + stickerId, + InlineTextContent( + placeholder = Placeholder( + width = 150.sp, + height = 150.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + CoilImage( + modifier = Modifier.size(150.dp), + imageModel = { Constants.Chat.STICKER_URL + stickerType }, + imageOptions = ImageOptions( + contentDescription = stickerType, + contentScale = ContentScale.Fit, + ), + loading = { + CircularProgressIndicator() + }, + failure = { + Icon(Icons.Filled.QuestionMark, null) + }, + previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), + ) + }, + ), + ) + } } } } Text( text = annotatedString, + color = color, modifier = modifier.pointerInput(Unit) { detectTapGestures { offset -> val position = annotatedString @@ -329,8 +380,9 @@ fun BBCodeText( private fun Preview_BBCodeText() { PluviaTheme { Surface { - BBCodeText( - text = """ + Column { + BBCodeText( + text = """ [h1]Header 1 text[/h1] [h2]Header 2 text[/h2] [h3]Header 3 text[/h3] @@ -347,8 +399,13 @@ private fun Preview_BBCodeText() { [code]Fixed-width font, preserves spaces[/code] Some ːsteamhappyː for ːsteamsadː testing. Hello World! [emoticon]steamhappy[/emoticon] - """.trimIndent(), - ) + """.trimIndent(), + ) + + Spacer(Modifier.height(14.dp)) + + BBCodeText(text = "[sticker type=\"Winter2019JingleIntensifies\" limit=\"0\"][/sticker]") + } } } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatMessageItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatMessageItem.kt index 1e99b3bf..94098398 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatMessageItem.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatMessageItem.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.OxGames.Pluvia.ui.component.BBCodeText import com.OxGames.Pluvia.ui.theme.PluviaTheme @Composable @@ -53,7 +54,7 @@ fun ChatBubble( ) { Column(modifier = Modifier.padding(contentPadding)) { // The message - Text( + BBCodeText( modifier = Modifier.align(if (fromLocal) Alignment.End else Alignment.Start), text = message, color = textColor, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 3fe26523..ee1ccea6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -1,6 +1,7 @@ package com.OxGames.Pluvia.ui.screen.chat import android.content.res.Configuration +import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons @@ -25,9 +27,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -65,7 +69,6 @@ import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.ui.util.ListItemImage import com.OxGames.Pluvia.utils.SteamUtils import com.OxGames.Pluvia.utils.getAvatarURL -import com.OxGames.Pluvia.utils.getProfileUrl import dagger.hilt.android.lifecycle.HiltViewModel import `in`.dragonbra.javasteam.enums.EPersonaState import `in`.dragonbra.javasteam.enums.EPersonaStateFlag @@ -94,9 +97,9 @@ class ChatViewModel @Inject constructor( private val _chatState = MutableStateFlow(ChatState()) val chatState: StateFlow = _chatState.asStateFlow() - var emoticonJob: Job? = null - var friendJob: Job? = null - var messagesJob: Job? = null + private var emoticonJob: Job? = null + private var friendJob: Job? = null + private var messagesJob: Job? = null override fun onCleared() { super.onCleared() @@ -108,35 +111,39 @@ class ChatViewModel @Inject constructor( messagesJob?.cancel() } - init { + fun setFriend(id: Long) { viewModelScope.launch { // Since were initiating a chat, refresh our list of emoticons and stickers SteamService.getEmoticonList() + SteamService.getRecentMessages(id) + SteamService.ackMessage(id) } emoticonJob = viewModelScope.launch { emoticonDao.getAll().collect { list -> + Timber.tag("ChatViewModel").d("Got Emotes: ${list.size}") _chatState.update { it.copy(emoticons = list) } } } - } - fun setFriend(id: Long) { friendJob = viewModelScope.launch { friendDao.findFriend(id).collect { friend -> if (friend == null) { throw RuntimeException("Friend is null and cannot proceed") } - + Timber.tag("ChatViewModel").d("Friend update $friend") _chatState.update { it.copy(friend = friend) } } } messagesJob = viewModelScope.launch { messagesDao.getAllMessagesForFriend(id).collect { list -> + Timber.tag("ChatViewModel").d("New messages ${list.size}") _chatState.update { it.copy(messages = list) } } } + + Timber.d("Chatting with $id") } } @@ -247,7 +254,7 @@ private fun ChatScreenContent( ) { DropdownMenuItem( text = { Text(text = "View Profile") }, - onClick = { uriHandler.openUri(steamFriend.id.getProfileUrl()) }, + onClick = { /* TODO */ }, ) DropdownMenuItem( text = { Text(text = "View Previous Names") }, @@ -272,19 +279,49 @@ private fun ChatScreenContent( // TODO Typing bar + Send + Emoji selector // TODO scroll to bottom // TODO scroll to bottom if we're ~3 messages slightly scrolled. - LazyColumn( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .imePadding(), - state = scrollState, - ) { - items(messages, key = { it.id }) { msg -> - ChatBubble( - message = msg.message, - timestamp = SteamUtils.fromSteamTime(msg.timestamp), - fromLocal = msg.fromLocal, - ) + + Crossfade(targetState = messages.isEmpty()) { state -> + when (state) { + true -> { + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .imePadding(), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.padding(horizontal = 24.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 8.dp, + ) { + Text( + modifier = Modifier.padding(24.dp), + text = "No chat history", + ) + } + } + } + + false -> { + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .imePadding(), + state = scrollState, + reverseLayout = true, + ) { + items(messages, key = { it.id }) { msg -> + ChatBubble( + message = msg.message, + timestamp = SteamUtils.fromSteamTime(msg.timestamp), + fromLocal = msg.fromLocal, + ) + } + } + } } } } @@ -310,7 +347,7 @@ private fun Preview_ChatScreenContent() { steamIDFriend = 76561198003805806, fromLocal = it % 3 == 0, message = "Hey!, ".repeat(it.plus(1).times(1)), - lowPriority = false, + // lowPriority = false, timestamp = 1737438789, ) }, From a6a60e9a8abd6156b49c74f0a324d5b8527ef0b1 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Tue, 28 Jan 2025 16:54:55 -0600 Subject: [PATCH 29/49] Flesh out some ideas for Profile and Chat, profile should contain the most items to manage a friend. --- .../com/OxGames/Pluvia/events/SteamEvent.kt | 2 + .../OxGames/Pluvia/service/SteamService.kt | 13 +- .../Pluvia/ui/screen/chat/ChatScreen.kt | 111 ++++++++---------- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 8 +- 4 files changed, 64 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt index f66a11df..da7e0bb5 100644 --- a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt +++ b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt @@ -2,6 +2,7 @@ package com.OxGames.Pluvia.events import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.enums.LoginResult +import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.AliasHistoryCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback sealed interface SteamEvent : Event { @@ -19,4 +20,5 @@ sealed interface SteamEvent : Event { // This isn't a SteamEvent, but since its the only one now, it can stay data class OnProfileInfo(val info: ProfileInfoCallback) : SteamEvent + data class OnAliasHistory(val info: AliasHistoryCallback) : SteamEvent } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 7e5cc098..303d29b1 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -83,6 +83,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamapps.callback.LicenseListCal import `in`.dragonbra.javasteam.steam.handlers.steamapps.callback.PICSProductInfoCallback import `in`.dragonbra.javasteam.steam.handlers.steamcloud.SteamCloud import `in`.dragonbra.javasteam.steam.handlers.steamfriends.SteamFriends +import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.AliasHistoryCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.FriendsListCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.NicknameListCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.PersonaStatesCallback @@ -1025,6 +1026,10 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun ackMessage(friendID: Long) = withContext(Dispatchers.IO) { instance?._unifiedFriends!!.ackMessage(friendID) } + + suspend fun requestAliasHistory(friendID: Long) = withContext(Dispatchers.IO) { + instance?.steamClient!!.getHandler()?.requestAliasHistory(SteamID(friendID)) + } } override fun onCreate() { @@ -1103,7 +1108,7 @@ class SteamService : Service(), IChallengeUrlChanged { add(subscribe(NicknameListCallback::class.java, ::onNicknameList)) add(subscribe(FriendsListCallback::class.java, ::onFriendsList)) add(subscribe(EmoticonListCallback::class.java, ::onEmoticonList)) - add(subscribe(ProfileInfoCallback::class.java, ::onProfileInfo)) + add(subscribe(AliasHistoryCallback::class.java) { PluviaApp.events.emit(SteamEvent.OnAliasHistory(it)) }) } } @@ -1427,12 +1432,6 @@ class SteamService : Service(), IChallengeUrlChanged { } } - private fun onProfileInfo(callback: ProfileInfoCallback) { - Timber.i("Getting profile info for ${callback.steamID}") - // TODO: We already wait with the caller, is this needed? - PluviaApp.events.emit(SteamEvent.OnProfileInfo(callback)) - } - @OptIn(ExperimentalStdlibApi::class) private fun onPersonaStateReceived(callback: PersonaStatesCallback) { // Ignore accounts that arent individuals diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index ee1ccea6..9414c9fc 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -5,9 +5,11 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,10 +20,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Person import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -36,18 +36,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -171,9 +170,8 @@ private fun ChatScreenContent( onBack: () -> Unit, ) { val snackbarHost = remember { SnackbarHostState() } - var expanded by remember { mutableStateOf(false) } - val uriHandler = LocalUriHandler.current val scrollState = rememberLazyListState() + val scope = rememberCoroutineScope() Scaffold( snackbarHost = { SnackbarHost(snackbarHost) }, @@ -187,7 +185,6 @@ private fun ChatScreenContent( ListItemImage( image = { steamFriend.avatarHash.getAvatarURL() }, size = 40.dp, - contentDescription = "Logged in account user profile", ) Spacer(modifier = Modifier.size(12.dp)) @@ -242,36 +239,14 @@ private fun ChatScreenContent( BackButton(onClick = onBack) }, actions = { - Box { - IconButton( - onClick = { expanded = !expanded }, - content = { Icon(imageVector = Icons.Default.MoreVert, contentDescription = null) }, - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - text = { Text(text = "View Profile") }, - onClick = { /* TODO */ }, - ) - DropdownMenuItem( - text = { Text(text = "View Previous Names") }, - onClick = { TODO() }, - ) - DropdownMenuItem( - text = { Text(text = "More Settings") }, - onClick = { - // TODO() - // 3. Friend settings: - // 3a. Add to favorites - // 3b. Block communication - // 3c. Friend (specific) notification settings - }, - ) - } - } + IconButton( + onClick = { + // TODO + val msg = "View profile not implemented!\nTry long pressing a friend in the friends list?" + scope.launch { snackbarHost.showSnackbar(msg) } + }, + content = { Icon(imageVector = Icons.Default.Person, contentDescription = null) }, + ) }, ) }, @@ -282,27 +257,7 @@ private fun ChatScreenContent( Crossfade(targetState = messages.isEmpty()) { state -> when (state) { - true -> { - Box( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .imePadding(), - contentAlignment = Alignment.Center, - ) { - Surface( - modifier = Modifier.padding(horizontal = 24.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - shadowElevation = 8.dp, - ) { - Text( - modifier = Modifier.padding(24.dp), - text = "No chat history", - ) - } - } - } + true -> NoChatHistoryBox(paddingValues = paddingValues) false -> { LazyColumn( @@ -313,6 +268,19 @@ private fun ChatScreenContent( state = scrollState, reverseLayout = true, ) { + stickyHeader { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 12.sp, + text = "Chatting is still an early feature.\n" + + "Please report any issues in the project repo.", + ) + } + } items(messages, key = { it.id }) { msg -> ChatBubble( message = msg.message, @@ -327,6 +295,29 @@ private fun ChatScreenContent( } } +@Composable +private fun NoChatHistoryBox(paddingValues: PaddingValues) { + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .imePadding(), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.padding(horizontal = 24.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 8.dp, + ) { + Text( + modifier = Modifier.padding(24.dp), + text = "No chat history", + ) + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun Preview_ChatScreenContent() { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index f5cbd04c..b25f6a46 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -404,9 +404,11 @@ private fun ProfileDetailsScreen( icon = Icons.Outlined.MoreVert, text = "More", onClick = { - // TODO more options, such as: - // Friend management: Remove, Block, Unblock - // Notification settings + // TODO: options like.... + // Add to favorites + // Block communication + // Friend (specific) notification settings + // Friend management: Remove, Block, Unblock, view Alias val msg = "'More' not available yet" Toast.makeText(context, msg, Toast.LENGTH_LONG).show() }, From c3cb80b71e526a8b3a659ba1cbb8b6e100d1c9cf Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Wed, 29 Jan 2025 15:59:00 -0600 Subject: [PATCH 30/49] Revert to original layout as shown in the JetChat sample. Remove fillMaxSize() from NavHost. --- .../java/com/OxGames/Pluvia/ui/PluviaMain.kt | 3 - .../Pluvia/ui/screen/chat/ChatInput.kt | 2 +- .../Pluvia/ui/screen/chat/ChatScreen.kt | 102 +++++++++++------- 3 files changed, 62 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt index 517f6c77..b925c3bf 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt @@ -3,14 +3,12 @@ package com.OxGames.Pluvia.ui import android.content.Context import android.content.Intent import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.hilt.navigation.compose.hiltViewModel @@ -385,7 +383,6 @@ fun PluviaMain( ) NavHost( - modifier = Modifier.fillMaxSize(), navController = navController, startDestination = PluviaScreen.LoginUser.route, ) { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt index ea60cbf2..0eeee6fb 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt @@ -78,8 +78,8 @@ enum class EmojiStickerSelector { @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatInput( - onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, + onMessageSent: (String) -> Unit, resetScroll: () -> Unit = {}, ) { var isEmoticonsShowing by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 9414c9fc..193065d9 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -1,16 +1,19 @@ package com.OxGames.Pluvia.ui.screen.chat import android.content.res.Configuration -import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -29,10 +32,13 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -41,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlatformTextStyle @@ -173,10 +180,21 @@ private fun ChatScreenContent( val scrollState = rememberLazyListState() val scope = rememberCoroutineScope() + // Needed? + val topBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + // Exclude ime and navigation bar padding so this can be added by the ChatInput composable + contentWindowInsets = ScaffoldDefaults + .contentWindowInsets + .exclude(WindowInsets.navigationBars) + .exclude(WindowInsets.ime), snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { CenterAlignedTopAppBar( + scrollBehavior = scrollBehavior, title = { Row( horizontalArrangement = Arrangement.Center, @@ -254,56 +272,58 @@ private fun ChatScreenContent( // TODO Typing bar + Send + Emoji selector // TODO scroll to bottom // TODO scroll to bottom if we're ~3 messages slightly scrolled. + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), - Crossfade(targetState = messages.isEmpty()) { state -> - when (state) { - true -> NoChatHistoryBox(paddingValues = paddingValues) - - false -> { - LazyColumn( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .imePadding(), - state = scrollState, - reverseLayout = true, - ) { - stickyHeader { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 12.sp, - text = "Chatting is still an early feature.\n" + - "Please report any issues in the project repo.", - ) - } - } - items(messages, key = { it.id }) { msg -> - ChatBubble( - message = msg.message, - timestamp = SteamUtils.fromSteamTime(msg.timestamp), - fromLocal = msg.fromLocal, + ) { + // Surround with Box in order to "Jump to Bottom" + Box(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = scrollState, + reverseLayout = true, + ) { + stickyHeader { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 12.sp, + text = "Chatting is still an early feature.\n" + + "Please report any issues in the project repo.", ) } } + items(messages, key = { it.id }) { msg -> + ChatBubble( + message = msg.message, + timestamp = SteamUtils.fromSteamTime(msg.timestamp), + fromLocal = msg.fromLocal, + ) + } } } + + ChatInput( + // let this element handle the padding so that the elevation is shown behind the + // navigation bar + modifier = Modifier + .navigationBarsPadding() + .imePadding(), + onMessageSent = { }, + resetScroll = { }, + ) } } } @Composable -private fun NoChatHistoryBox(paddingValues: PaddingValues) { - Box( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .imePadding(), - contentAlignment = Alignment.Center, - ) { +private fun NoChatHistoryBox() { + Box(contentAlignment = Alignment.Center) { Surface( modifier = Modifier.padding(horizontal = 24.dp), shape = RoundedCornerShape(16.dp), From 3babec42e79572cf49e18dd8aa68c80572751fa1 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Wed, 29 Jan 2025 22:55:31 -0600 Subject: [PATCH 31/49] Fix soft input resizing bug. --- app/src/main/AndroidManifest.xml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 97c4300b..10093304 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,11 +24,15 @@ android:label="@string/app_name" android:roundIcon="${roundIcon}" android:supportsRtl="true" - android:theme="@style/Theme.Pluvia" - android:windowSoftInputMode="adjustResize"> + android:theme="@style/Theme.Pluvia"> + From a036ebb709e221f53f50cc94a22ba035f76e35e3 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Wed, 29 Jan 2025 23:01:27 -0600 Subject: [PATCH 32/49] Start finalizing chat screen. Start finalizing user input, with emoticon and sticker support. Refactor state and viewmodel into the proper packages. Remove colon marker for emoticons. Fix wrong EMsg for Emoticon list. Promote SteamUtils.fromSteamTime() to be stable. --- .../com/OxGames/Pluvia/db/dao/EmoticonDao.kt | 3 + .../OxGames/Pluvia/service/SteamService.kt | 25 +- .../service/callback/EmoticonListCallback.kt | 7 +- .../Pluvia/service/handler/PluviaHandler.kt | 2 +- .../com/OxGames/Pluvia/ui/data/ChatState.kt | 12 + .../OxGames/Pluvia/ui/model/ChatViewModel.kt | 77 +++ .../Pluvia/ui/screen/chat/ChatInput.kt | 336 +++++-------- .../Pluvia/ui/screen/chat/ChatScreen.kt | 460 +++++++++--------- .../com/OxGames/Pluvia/utils/SteamUtils.kt | 2 +- 9 files changed, 473 insertions(+), 451 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/data/ChatState.kt create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt b/app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt index 2630c9dd..4d3810d2 100644 --- a/app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt +++ b/app/src/main/java/com/OxGames/Pluvia/db/dao/EmoticonDao.kt @@ -17,6 +17,9 @@ interface EmoticonDao { @Query("SELECT * FROM emoticon ORDER BY isSticker DESC, appID DESC, name DESC") fun getAll(): Flow> + @Query("SELECT * FROM emoticon ORDER BY isSticker DESC, appID DESC, name DESC") + fun getAllAsList(): List + @Query("DELETE FROM emoticon") suspend fun deleteAll() diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 303d29b1..055677f5 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -13,6 +13,7 @@ import com.OxGames.Pluvia.data.BranchInfo import com.OxGames.Pluvia.data.ConfigInfo import com.OxGames.Pluvia.data.DepotInfo import com.OxGames.Pluvia.data.DownloadInfo +import com.OxGames.Pluvia.data.Emoticon import com.OxGames.Pluvia.data.GameProcessInfo import com.OxGames.Pluvia.data.LaunchInfo import com.OxGames.Pluvia.data.LibraryAssetsInfo @@ -1011,6 +1012,10 @@ class SteamService : Service(), IChallengeUrlChanged { instance?.steamClient!!.getHandler()!!.getEmoticonList() } + suspend fun fetchEmoticons(): List = withContext(Dispatchers.IO) { + instance?.emoticonDao!!.getAllAsList() + } + suspend fun getProfileInfo(friendID: SteamID): ProfileInfoCallback = withContext(Dispatchers.IO) { instance?._steamFriends!!.requestProfileInfo(friendID).await() } @@ -1073,16 +1078,16 @@ class SteamService : Service(), IChallengeUrlChanged { } // create our steam client instance - steamClient = SteamClient(configuration) - - steamClient!!.addHandler(PluviaHandler()) - - // remove callbacks we're not using. - steamClient!!.removeHandler(SteamGameServer::class.java) - steamClient!!.removeHandler(SteamMasterServer::class.java) - steamClient!!.removeHandler(SteamWorkshop::class.java) - steamClient!!.removeHandler(SteamScreenshots::class.java) - steamClient!!.removeHandler(SteamUserStats::class.java) + steamClient = SteamClient(configuration).apply { + addHandler(PluviaHandler()) + + // remove callbacks we're not using. + removeHandler(SteamGameServer::class.java) + removeHandler(SteamMasterServer::class.java) + removeHandler(SteamWorkshop::class.java) + removeHandler(SteamScreenshots::class.java) + removeHandler(SteamUserStats::class.java) + } // create the callback manager which will route callbacks to function calls callbackManager = CallbackManager(steamClient!!) diff --git a/app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt b/app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt index ee95642a..6a756bdc 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/callback/EmoticonListCallback.kt @@ -18,7 +18,12 @@ class EmoticonListCallback(packetMsg: IPacketMsg) : CallbackMsg() { jobID = resp.targetJobID emoteList = buildList { - addAll(resp.body.emoticonsList.map { Emoticon(name = it.name, appID = it.appid, isSticker = false) }) + addAll( + resp.body.emoticonsList.map { + val fixedName = it.name.substring(1, it.name.length - 1) + Emoticon(name = fixedName, appID = it.appid, isSticker = false) + }, + ) addAll(resp.body.stickersList.map { Emoticon(name = it.name, appID = it.appid, isSticker = true) }) } } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt b/app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt index 4dbe95a1..3b416395 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/handler/PluviaHandler.kt @@ -15,7 +15,7 @@ class PluviaHandler : ClientMsgHandler() { companion object { fun getCallback(packetMsg: IPacketMsg): CallbackMsg? = when (packetMsg.msgType) { - EMsg.ClientGetEmoticonList -> EmoticonListCallback(packetMsg) + EMsg.ClientEmoticonList -> EmoticonListCallback(packetMsg) else -> null } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/ChatState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/ChatState.kt new file mode 100644 index 00000000..c1def1b2 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/ChatState.kt @@ -0,0 +1,12 @@ +package com.OxGames.Pluvia.ui.data + +import com.OxGames.Pluvia.data.Emoticon +import com.OxGames.Pluvia.data.FriendMessage +import com.OxGames.Pluvia.data.SteamFriend + +data class ChatState( + val friend: SteamFriend = SteamFriend(0), + val messages: List = listOf(), + val emoticons: List = listOf(), + val isLoading: Boolean = true, +) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt new file mode 100644 index 00000000..c13a66f3 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt @@ -0,0 +1,77 @@ +package com.OxGames.Pluvia.ui.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.OxGames.Pluvia.db.dao.EmoticonDao +import com.OxGames.Pluvia.db.dao.FriendMessagesDao +import com.OxGames.Pluvia.db.dao.SteamFriendDao +import com.OxGames.Pluvia.service.SteamService +import com.OxGames.Pluvia.ui.data.ChatState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel +class ChatViewModel @Inject constructor( + private val friendDao: SteamFriendDao, + private val messagesDao: FriendMessagesDao, + private val emoticonDao: EmoticonDao, +) : ViewModel() { + + private val _chatState = MutableStateFlow(ChatState()) + val chatState: StateFlow = _chatState.asStateFlow() + + private var chatJob: Job? = null + + override fun onCleared() { + super.onCleared() + + Timber.d("onCleared") + + chatJob?.cancel() + } + + fun setFriend(id: Long) { + Timber.d("Chatting with $id") + chatJob?.cancel() + + chatJob = viewModelScope.launch { + launch { + // Since were initiating a chat, refresh our list of emoticons and stickers + SteamService.getEmoticonList() + SteamService.getRecentMessages(id) + SteamService.ackMessage(id) + } + + launch { + emoticonDao.getAll().collect { list -> + Timber.tag("ChatViewModel").d("Got Emotes: ${list.size}") + _chatState.update { it.copy(emoticons = list) } + } + } + + launch { + friendDao.findFriend(id).collect { friend -> + if (friend == null) { + throw RuntimeException("Friend is null and cannot proceed") + } + Timber.tag("ChatViewModel").d("Friend update $friend") + _chatState.update { it.copy(friend = friend) } + } + } + + launch { + messagesDao.getAllMessagesForFriend(id).collect { list -> + Timber.tag("ChatViewModel").d("New messages ${list.size}") + _chatState.update { it.copy(messages = list) } + } + } + } + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt index 0eeee6fb..5e8ac4eb 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt @@ -5,7 +5,6 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -15,15 +14,18 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.outlined.EmojiEmotions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -35,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,6 +50,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.semantics @@ -54,11 +59,18 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.OxGames.Pluvia.Constants +import com.OxGames.Pluvia.R +import com.OxGames.Pluvia.data.Emoticon +import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.theme.PluviaTheme +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage +import timber.log.Timber /** * Heavily referenced from: @@ -68,9 +80,8 @@ import com.OxGames.Pluvia.ui.theme.PluviaTheme val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey -private const val EMOJI_COLUMNS = 10 - enum class EmojiStickerSelector { + NONE, EMOJI, STICKER, } @@ -80,13 +91,14 @@ enum class EmojiStickerSelector { fun ChatInput( modifier: Modifier = Modifier, onMessageSent: (String) -> Unit, + onSticker: (String) -> Unit, resetScroll: () -> Unit = {}, ) { - var isEmoticonsShowing by rememberSaveable { mutableStateOf(false) } - val dismissKeyboard = { isEmoticonsShowing = false } + var isEmoticonsShowing by rememberSaveable { mutableStateOf(EmojiStickerSelector.NONE) } + val dismissKeyboard = { isEmoticonsShowing = EmojiStickerSelector.NONE } // Intercept back navigation if there's a InputSelector visible - if (!isEmoticonsShowing) { + if (isEmoticonsShowing != EmojiStickerSelector.NONE) { BackHandler(onBack = dismissKeyboard) } @@ -103,11 +115,11 @@ fun ChatInput( textFieldValue = textState, onTextChanged = { textState = it }, // Only show the keyboard if there's no input selector and text field has focus - keyboardShown = !isEmoticonsShowing && textFieldFocusState, + keyboardShown = isEmoticonsShowing == EmojiStickerSelector.NONE && textFieldFocusState, // Close extended selector if text field receives focus onTextFieldFocused = { focused -> if (focused) { - isEmoticonsShowing = false + isEmoticonsShowing = EmojiStickerSelector.NONE resetScroll() } textFieldFocusState = focused @@ -121,14 +133,18 @@ fun ChatInput( }, isEmoticonShowing = isEmoticonsShowing, onEmoticonClick = { - isEmoticonsShowing = !isEmoticonsShowing + isEmoticonsShowing = if (isEmoticonsShowing == EmojiStickerSelector.NONE) { + EmojiStickerSelector.EMOJI + } else { + EmojiStickerSelector.NONE + } }, ) SelectorExpanded( isEmoticonsShowing = isEmoticonsShowing, - onCloseRequested = dismissKeyboard, onTextAdded = { textState = textState.addText(it) }, + onStickerAdded = onSticker, ) } } @@ -150,31 +166,43 @@ private fun TextFieldValue.addText(newString: String): TextFieldValue { @Composable private fun SelectorExpanded( - isEmoticonsShowing: Boolean, - onCloseRequested: () -> Unit, + isEmoticonsShowing: EmojiStickerSelector, onTextAdded: (String) -> Unit, + onStickerAdded: (String) -> Unit, ) { - if (!isEmoticonsShowing) return + if (isEmoticonsShowing == EmojiStickerSelector.NONE) return // Request focus to force the TextField to lose it val focusRequester = FocusRequester() // If the selector is shown, always request focus to trigger a TextField.onFocusChange. SideEffect { - if (isEmoticonsShowing) { + if (isEmoticonsShowing == EmojiStickerSelector.EMOJI || isEmoticonsShowing == EmojiStickerSelector.STICKER) { focusRequester.requestFocus() } } var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) } + var emotes by rememberSaveable { mutableStateOf(listOf()) } + LaunchedEffect(isEmoticonsShowing) { + emotes = SteamService.fetchEmoticons() + Timber.d("Emote size: ${emotes.size}") + } + Surface(tonalElevation = 8.dp) { - if (isEmoticonsShowing) { - EmojiSelector( + when (isEmoticonsShowing) { + EmojiStickerSelector.EMOJI, + EmojiStickerSelector.STICKER, + -> EmojiSelector( + emotes = emotes, focusRequester = focusRequester, emojiSelector = selected, onInnerSelection = { selected = it }, onTextAdded = onTextAdded, + onStickerAdded = onStickerAdded, ) + + else -> throw NotImplementedError("Invalid Emoji selector $isEmoticonsShowing") } } } @@ -188,7 +216,7 @@ private fun UserInputText( keyboardShown: Boolean, onTextFieldFocused: (Boolean) -> Unit, onMessageSent: () -> Unit, - isEmoticonShowing: Boolean, + isEmoticonShowing: EmojiStickerSelector, onEmoticonClick: () -> Unit, ) { Box( @@ -219,7 +247,7 @@ private fun UserInputTextField( textFieldValue: TextFieldValue, onTextChanged: (TextFieldValue) -> Unit, onTextFieldFocused: (Boolean) -> Unit, - isEmoticonShowing: Boolean, + isEmoticonShowing: EmojiStickerSelector, onEmoticonClick: () -> Unit, keyboardType: KeyboardType, onMessageSent: () -> Unit, @@ -249,10 +277,10 @@ private fun UserInputTextField( Text(text = "Send a message") }, leadingIcon = { - val colors = if (!isEmoticonShowing) { + val colors = if (isEmoticonShowing == EmojiStickerSelector.NONE) { IconButtonDefaults.iconButtonColors() } else { - IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.onPrimary) + IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.onSecondary) } IconButton( @@ -298,10 +326,12 @@ private fun UserInputTextField( @Composable fun EmojiSelector( + emotes: List, focusRequester: FocusRequester, emojiSelector: EmojiStickerSelector, onInnerSelection: (EmojiStickerSelector) -> Unit, onTextAdded: (String) -> Unit, + onStickerAdded: (String) -> Unit, ) { Column( modifier = Modifier @@ -327,17 +357,14 @@ fun EmojiSelector( ) } - when (emojiSelector) { - EmojiStickerSelector.EMOJI -> { - Row(modifier = Modifier.verticalScroll(rememberScrollState())) { - EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp)) - } - } - - EmojiStickerSelector.STICKER -> { - Text("TODO: Stickers") - } - } + EmoteTable( + modifier = Modifier.padding(8.dp), + emoticons = emotes.filter { + if (emojiSelector == EmojiStickerSelector.EMOJI) !it.isSticker else it.isSticker + }, + onTextAdded = onTextAdded, + onStickerAdded = onStickerAdded, + ) } } @@ -365,43 +392,60 @@ fun ExtendedSelectorInnerButton( onClick = onClick, colors = colors, contentPadding = PaddingValues(0.dp), - content = { - Text( - text = text, - style = MaterialTheme.typography.titleSmall, - ) - }, + content = { Text(text = text, style = MaterialTheme.typography.titleSmall) }, ) } @Composable -fun EmojiTable( - onTextAdded: (String) -> Unit, +fun EmoteTable( modifier: Modifier = Modifier, + emoticons: List, + onTextAdded: (String) -> Unit, + onStickerAdded: (String) -> Unit, ) { - Column(modifier.fillMaxWidth()) { - repeat(4) { x -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(EMOJI_COLUMNS) { y -> - val emoji = emojis[x * EMOJI_COLUMNS + y] - Text( - modifier = Modifier - .clickable(onClick = { onTextAdded(emoji) }) - .sizeIn(minWidth = 42.dp, minHeight = 42.dp) - .padding(8.dp), - text = emoji, - style = LocalTextStyle.current.copy( - fontSize = 18.sp, - textAlign = TextAlign.Center, - ), - ) - } + LazyVerticalGrid( + modifier = modifier.height(270.dp), + columns = GridCells.Adaptive(64.dp), + content = { + items(emoticons) { emoticon -> + CoilImage( + modifier = Modifier + .padding(8.dp) + .size(64.dp) + .clickable { + if (emoticon.isSticker) { + onStickerAdded(emoticon.name) + } else { + onTextAdded(emoticon.name) + } + }, + imageModel = { + val url = if (emoticon.isSticker) { + Constants.Chat.STICKER_URL + } else { + Constants.Chat.EMOTICON_URL + } + url + emoticon.name + }, + imageOptions = ImageOptions( + contentDescription = "${if (emoticon.isSticker) "Sticker" else "Emoticon"} ${emoticon.name}", + contentScale = ContentScale.Inside, + ), + loading = { + CircularProgressIndicator( + modifier = Modifier + .padding(8.dp) + .size(32.dp), + ) + }, + failure = { + Icon(Icons.Filled.QuestionMark, null) + }, + previewPlaceholder = painterResource(R.drawable.icon_mono_foreground), + ) } - } - } + }, + ) } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @@ -414,166 +458,30 @@ fun Preview_ChatInput() { .fillMaxSize(), ) { Box(modifier = Modifier.weight(1f)) - ChatInput(onMessageSent = {}) + ChatInput(onMessageSent = {}, onSticker = {}) } } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -fun Preview_Emoticons() { - val focusRequester = FocusRequester() - PluviaTheme { - EmojiSelector( - focusRequester = focusRequester, - emojiSelector = EmojiStickerSelector.EMOJI, - onInnerSelection = {}, - onTextAdded = {}, - ) - } +internal class EmojiSelectorPreview : PreviewParameterProvider { + override val values = sequenceOf(EmojiStickerSelector.EMOJI, EmojiStickerSelector.STICKER) } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable -fun Preview_Stickers() { - val focusRequester = FocusRequester() +fun Preview_EmojiSelector( + @PreviewParameter(EmojiSelectorPreview::class) state: EmojiStickerSelector, +) { PluviaTheme { EmojiSelector( - focusRequester = focusRequester, - emojiSelector = EmojiStickerSelector.STICKER, + emotes = List(25) { + Emoticon("emote$it", appID = it, isSticker = state == EmojiStickerSelector.STICKER) + }, + focusRequester = FocusRequester(), + emojiSelector = state, onInnerSelection = {}, onTextAdded = {}, + onStickerAdded = {}, ) } } - -private val emojis = listOf( - "\ud83d\ude00", // Grinning Face - "\ud83d\ude01", // Grinning Face With Smiling Eyes - "\ud83d\ude02", // Face With Tears of Joy - "\ud83d\ude03", // Smiling Face With Open Mouth - "\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes - "\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat - "\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes - "\ud83d\ude09", // Winking Face - "\ud83d\ude0a", // Smiling Face With Smiling Eyes - "\ud83d\ude0b", // Face Savouring Delicious Food - "\ud83d\ude0e", // Smiling Face With Sunglasses - "\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes - "\ud83d\ude18", // Face Throwing a Kiss - "\ud83d\ude17", // Kissing Face - "\ud83d\ude19", // Kissing Face With Smiling Eyes - "\ud83d\ude1a", // Kissing Face With Closed Eyes - "\u263a", // White Smiling Face - "\ud83d\ude42", // Slightly Smiling Face - "\ud83e\udd17", // Hugging Face - "\ud83d\ude07", // Smiling Face With Halo - "\ud83e\udd13", // Nerd Face - "\ud83e\udd14", // Thinking Face - "\ud83d\ude10", // Neutral Face - "\ud83d\ude11", // Expressionless Face - "\ud83d\ude36", // Face Without Mouth - "\ud83d\ude44", // Face With Rolling Eyes - "\ud83d\ude0f", // Smirking Face - "\ud83d\ude23", // Persevering Face - "\ud83d\ude25", // Disappointed but Relieved Face - "\ud83d\ude2e", // Face With Open Mouth - "\ud83e\udd10", // Zipper-Mouth Face - "\ud83d\ude2f", // Hushed Face - "\ud83d\ude2a", // Sleepy Face - "\ud83d\ude2b", // Tired Face - "\ud83d\ude34", // Sleeping Face - "\ud83d\ude0c", // Relieved Face - "\ud83d\ude1b", // Face With Stuck-Out Tongue - "\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye - "\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes - "\ud83d\ude12", // Unamused Face - "\ud83d\ude13", // Face With Cold Sweat - "\ud83d\ude14", // Pensive Face - "\ud83d\ude15", // Confused Face - "\ud83d\ude43", // Upside-Down Face - "\ud83e\udd11", // Money-Mouth Face - "\ud83d\ude32", // Astonished Face - "\ud83d\ude37", // Face With Medical Mask - "\ud83e\udd12", // Face With Thermometer - "\ud83e\udd15", // Face With Head-Bandage - "\u2639", // White Frowning Face - "\ud83d\ude41", // Slightly Frowning Face - "\ud83d\ude16", // Confounded Face - "\ud83d\ude1e", // Disappointed Face - "\ud83d\ude1f", // Worried Face - "\ud83d\ude24", // Face With Look of Triumph - "\ud83d\ude22", // Crying Face - "\ud83d\ude2d", // Loudly Crying Face - "\ud83d\ude26", // Frowning Face With Open Mouth - "\ud83d\ude27", // Anguished Face - "\ud83d\ude28", // Fearful Face - "\ud83d\ude29", // Weary Face - "\ud83d\ude2c", // Grimacing Face - "\ud83d\ude30", // Face With Open Mouth and Cold Sweat - "\ud83d\ude31", // Face Screaming in Fear - "\ud83d\ude33", // Flushed Face - "\ud83d\ude35", // Dizzy Face - "\ud83d\ude21", // Pouting Face - "\ud83d\ude20", // Angry Face - "\ud83d\ude08", // Smiling Face With Horns - "\ud83d\udc7f", // Imp - "\ud83d\udc79", // Japanese Ogre - "\ud83d\udc7a", // Japanese Goblin - "\ud83d\udc80", // Skull - "\ud83d\udc7b", // Ghost - "\ud83d\udc7d", // Extraterrestrial Alien - "\ud83e\udd16", // Robot Face - "\ud83d\udca9", // Pile of Poo - "\ud83d\ude3a", // Smiling Cat Face With Open Mouth - "\ud83d\ude38", // Grinning Cat Face With Smiling Eyes - "\ud83d\ude39", // Cat Face With Tears of Joy - "\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes - "\ud83d\ude3c", // Cat Face With Wry Smile - "\ud83d\ude3d", // Kissing Cat Face With Closed Eyes - "\ud83d\ude40", // Weary Cat Face - "\ud83d\ude3f", // Crying Cat Face - "\ud83d\ude3e", // Pouting Cat Face - "\ud83d\udc66", // Boy - "\ud83d\udc67", // Girl - "\ud83d\udc68", // Man - "\ud83d\udc69", // Woman - "\ud83d\udc74", // Older Man - "\ud83d\udc75", // Older Woman - "\ud83d\udc76", // Baby - "\ud83d\udc71", // Person With Blond Hair - "\ud83d\udc6e", // Police Officer - "\ud83d\udc72", // Man With Gua Pi Mao - "\ud83d\udc73", // Man With Turban - "\ud83d\udc77", // Construction Worker - "\u26d1", // Helmet With White Cross - "\ud83d\udc78", // Princess - "\ud83d\udc82", // Guardsman - "\ud83d\udd75", // Sleuth or Spy - "\ud83c\udf85", // Father Christmas - "\ud83d\udc70", // Bride With Veil - "\ud83d\udc7c", // Baby Angel - "\ud83d\udc86", // Face Massage - "\ud83d\udc87", // Haircut - "\ud83d\ude4d", // Person Frowning - "\ud83d\ude4e", // Person With Pouting Face - "\ud83d\ude45", // Face With No Good Gesture - "\ud83d\ude46", // Face With OK Gesture - "\ud83d\udc81", // Information Desk Person - "\ud83d\ude4b", // Happy Person Raising One Hand - "\ud83d\ude47", // Person Bowing Deeply - "\ud83d\ude4c", // Person Raising Both Hands in Celebration - "\ud83d\ude4f", // Person With Folded Hands - "\ud83d\udde3", // Speaking Head in Silhouette - "\ud83d\udc64", // Bust in Silhouette - "\ud83d\udc65", // Busts in Silhouette - "\ud83d\udeb6", // Pedestrian - "\ud83c\udfc3", // Runner - "\ud83d\udc6f", // Woman With Bunny Ears - "\ud83d\udc83", // Dancer - "\ud83d\udd74", // Man in Business Suit Levitating - "\ud83d\udc6b", // Man and Woman Holding Hands - "\ud83d\udc6c", // Two Men Holding Hands - "\ud83d\udc6d", // Two Women Holding Hands - "\ud83d\udc8f", // Kiss -) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 193065d9..cb55ec5b 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -1,6 +1,11 @@ package com.OxGames.Pluvia.ui.screen.chat import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,12 +22,14 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown import androidx.compose.material.icons.filled.Person import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,21 +40,21 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlatformTextStyle @@ -56,102 +63,25 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import com.OxGames.Pluvia.data.Emoticon import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.SteamFriend -import com.OxGames.Pluvia.db.dao.EmoticonDao -import com.OxGames.Pluvia.db.dao.FriendMessagesDao -import com.OxGames.Pluvia.db.dao.SteamFriendDao -import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.component.topbar.BackButton +import com.OxGames.Pluvia.ui.data.ChatState +import com.OxGames.Pluvia.ui.model.ChatViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.ui.util.ListItemImage import com.OxGames.Pluvia.utils.SteamUtils import com.OxGames.Pluvia.utils.getAvatarURL -import dagger.hilt.android.lifecycle.HiltViewModel import `in`.dragonbra.javasteam.enums.EPersonaState import `in`.dragonbra.javasteam.enums.EPersonaStateFlag -import javax.inject.Inject -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber - -data class ChatState( - val friend: SteamFriend = SteamFriend(0), - val messages: List = listOf(), - val emoticons: List = listOf(), -) - -@HiltViewModel -class ChatViewModel @Inject constructor( - private val friendDao: SteamFriendDao, - private val messagesDao: FriendMessagesDao, - private val emoticonDao: EmoticonDao, -) : ViewModel() { - - private val _chatState = MutableStateFlow(ChatState()) - val chatState: StateFlow = _chatState.asStateFlow() - - private var emoticonJob: Job? = null - private var friendJob: Job? = null - private var messagesJob: Job? = null - - override fun onCleared() { - super.onCleared() - - Timber.d("onCleared") - - emoticonJob?.cancel() - friendJob?.cancel() - messagesJob?.cancel() - } - - fun setFriend(id: Long) { - viewModelScope.launch { - // Since were initiating a chat, refresh our list of emoticons and stickers - SteamService.getEmoticonList() - SteamService.getRecentMessages(id) - SteamService.ackMessage(id) - } - - emoticonJob = viewModelScope.launch { - emoticonDao.getAll().collect { list -> - Timber.tag("ChatViewModel").d("Got Emotes: ${list.size}") - _chatState.update { it.copy(emoticons = list) } - } - } - - friendJob = viewModelScope.launch { - friendDao.findFriend(id).collect { friend -> - if (friend == null) { - throw RuntimeException("Friend is null and cannot proceed") - } - Timber.tag("ChatViewModel").d("Friend update $friend") - _chatState.update { it.copy(friend = friend) } - } - } - - messagesJob = viewModelScope.launch { - messagesDao.getAllMessagesForFriend(id).collect { list -> - Timber.tag("ChatViewModel").d("New messages ${list.size}") - _chatState.update { it.copy(messages = list) } - } - } - - Timber.d("Chatting with $id") - } -} @Composable fun ChatScreen( @@ -160,11 +90,13 @@ fun ChatScreen( onBack: () -> Unit, ) { val state by viewModel.chatState.collectAsStateWithLifecycle() - viewModel.setFriend(friendId) + + LaunchedEffect(friendId) { + viewModel.setFriend(friendId) + } ChatScreenContent( - steamFriend = state.friend, - messages = state.messages, + state = state, onBack = onBack, ) } @@ -172,158 +104,147 @@ fun ChatScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatScreenContent( - steamFriend: SteamFriend, - messages: List, + state: ChatState, onBack: () -> Unit, ) { val snackbarHost = remember { SnackbarHostState() } val scrollState = rememberLazyListState() val scope = rememberCoroutineScope() - // Needed? - val topBarState = rememberTopAppBarState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState) - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), // Exclude ime and navigation bar padding so this can be added by the ChatInput composable contentWindowInsets = ScaffoldDefaults .contentWindowInsets .exclude(WindowInsets.navigationBars) .exclude(WindowInsets.ime), - snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { - CenterAlignedTopAppBar( - scrollBehavior = scrollBehavior, - title = { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - ListItemImage( - image = { steamFriend.avatarHash.getAvatarURL() }, - size = 40.dp, - ) - - Spacer(modifier = Modifier.size(12.dp)) - - Column { - CompositionLocalProvider( - LocalContentColor provides steamFriend.statusColor, - LocalTextStyle provides TextStyle( - lineHeight = 1.em, - platformStyle = PlatformTextStyle(includeFontPadding = false), - ), - ) { - Text( - overflow = TextOverflow.Ellipsis, - fontSize = 20.sp, - maxLines = 1, - text = buildAnnotatedString { - append(steamFriend.nameOrNickname) - if (steamFriend.statusIcon != null) { - append(" ") - appendInlineContent("icon", "[icon]") - } - }, - inlineContent = mapOf( - "icon" to InlineTextContent( - Placeholder( - width = 16.sp, - height = 16.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - children = { - steamFriend.statusIcon?.let { - Icon(imageVector = it, tint = Color.LightGray, contentDescription = it.name) - } - }, - ), - ), - ) - - Text( - text = steamFriend.isPlayingGameName, - overflow = TextOverflow.Ellipsis, - fontSize = 12.sp, - maxLines = 1, - color = LocalContentColor.current.copy(alpha = .75f), - ) - } - } - } - }, - navigationIcon = { - BackButton(onClick = onBack) - }, - actions = { - IconButton( - onClick = { - // TODO - val msg = "View profile not implemented!\nTry long pressing a friend in the friends list?" - scope.launch { snackbarHost.showSnackbar(msg) } - }, - content = { Icon(imageVector = Icons.Default.Person, contentDescription = null) }, - ) + ChatTopBar( + steamFriend = state.friend, + onBack = onBack, + onProfile = { + // TODO + val msg = "View profile not implemented!\nTry long pressing a friend in the friends list?" + scope.launch { snackbarHost.showSnackbar(msg) } }, ) }, ) { paddingValues -> - // TODO Typing bar + Send + Emoji selector - // TODO scroll to bottom - // TODO scroll to bottom if we're ~3 messages slightly scrolled. Column( modifier = Modifier .fillMaxSize() .padding(paddingValues), - ) { - // Surround with Box in order to "Jump to Bottom" - Box(modifier = Modifier.weight(1f)) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = scrollState, - reverseLayout = true, - ) { - stickyHeader { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 12.sp, - text = "Chatting is still an early feature.\n" + - "Please report any issues in the project repo.", - ) - } - } - items(messages, key = { it.id }) { msg -> - ChatBubble( - message = msg.message, - timestamp = SteamUtils.fromSteamTime(msg.timestamp), - fromLocal = msg.fromLocal, - ) - } - } - } + ChatMessages( + modifier = Modifier.weight(1f), + snackbarHost = snackbarHost, + state = state, + scrollState = scrollState, + ) + // let this element handle the padding so that the elevation is shown behind the + // navigation bar ChatInput( - // let this element handle the padding so that the elevation is shown behind the - // navigation bar modifier = Modifier .navigationBarsPadding() .imePadding(), - onMessageSent = { }, - resetScroll = { }, + onMessageSent = { + // TODO + }, + onSticker = { + // TODO + }, + resetScroll = { + // TODO + }, ) } } } +@Composable +private fun ChatMessages( + modifier: Modifier = Modifier, + snackbarHost: SnackbarHostState, + state: ChatState, + scrollState: LazyListState, +) { + val scope = rememberCoroutineScope() + + Box(modifier = modifier) { + AnimatedVisibility(state.messages.isEmpty()) { + NoChatHistoryBox() + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = scrollState, + reverseLayout = true, + ) { + stickyHeader { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 12.sp, + text = "Chatting is still an early feature.\n" + + "Please report any issues in the project repo.", + ) + } + } + + items(state.messages, key = { it.id }) { msg -> + ChatBubble( + message = msg.message, + timestamp = SteamUtils.fromSteamTime(msg.timestamp), + fromLocal = msg.fromLocal, + ) + } + } + + // Show the button if the first visible item is not the first one or if the offset is + // greater than the threshold. + val jumpToBottomButtonEnabled by remember { + derivedStateOf { + // Arbitrary threshold to show the button + scrollState.firstVisibleItemIndex > 3 + } + } + + AnimatedVisibility( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + visible = jumpToBottomButtonEnabled, + enter = fadeIn() + scaleIn(), + exit = scaleOut() + fadeOut(), + ) { + SmallFloatingActionButton( + onClick = { + scope.launch { + scrollState.animateScrollToItem(0) + } + }, + content = { + Icon(imageVector = Icons.Default.KeyboardDoubleArrowDown, contentDescription = null) + }, + ) + } + + SnackbarHost( + modifier = Modifier.align(Alignment.BottomCenter), + hostState = snackbarHost, + ) + } +} + @Composable private fun NoChatHistoryBox() { - Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { Surface( modifier = Modifier.padding(horizontal = 24.dp), shape = RoundedCornerShape(16.dp), @@ -333,35 +254,126 @@ private fun NoChatHistoryBox() { Text( modifier = Modifier.padding(24.dp), text = "No chat history", + textAlign = TextAlign.Center, ) } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatTopBar( + steamFriend: SteamFriend, + onBack: () -> Unit, + onProfile: () -> Unit, +) { + CenterAlignedTopAppBar( + title = { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + ListItemImage( + image = { steamFriend.avatarHash.getAvatarURL() }, + size = 40.dp, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column { + CompositionLocalProvider( + LocalContentColor provides steamFriend.statusColor, + LocalTextStyle provides TextStyle( + lineHeight = 1.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + ) { + Text( + overflow = TextOverflow.Ellipsis, + fontSize = 20.sp, + maxLines = 1, + text = buildAnnotatedString { + append(steamFriend.nameOrNickname) + if (steamFriend.statusIcon != null) { + append(" ") + appendInlineContent("icon", "[icon]") + } + }, + inlineContent = mapOf( + "icon" to InlineTextContent( + Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + steamFriend.statusIcon?.let { + Icon(imageVector = it, tint = Color.LightGray, contentDescription = it.name) + } + }, + ), + ), + ) + + Text( + text = steamFriend.isPlayingGameName, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + maxLines = 1, + color = LocalContentColor.current.copy(alpha = .75f), + ) + } + } + } + }, + navigationIcon = { + BackButton(onClick = onBack) + }, + actions = { + IconButton( + onClick = onProfile, + content = { Icon(imageVector = Icons.Default.Person, contentDescription = null) }, + ) + }, + ) +} + +internal class MessagesPreviewProvider : PreviewParameterProvider> { + override val values = sequenceOf( + emptyList(), + List(20) { + FriendMessage( + id = it.plus(1).toLong(), + steamIDFriend = 76561198003805806, + fromLocal = it % 3 == 0, + message = "Hey!, ".repeat(it.plus(1).times(1)), + // lowPriority = false, + timestamp = 1737438789, + ) + }, + ) +} + +/* NOTE: Launching this composable in preview will make the TopBar shift up.*/ @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable -private fun Preview_ChatScreenContent() { +private fun Preview_ChatScreenContent( + @PreviewParameter(MessagesPreviewProvider::class) messages: List, +) { PluviaTheme { ChatScreenContent( - steamFriend = SteamFriend( - id = 76561198003805806, - state = EPersonaState.Online, - avatarHash = "cfc54391f2f2ba745b701ad1287f73e50dc26d74", - name = "Lossy", - nickname = "Lossy with a nickname which should clip", - gameAppID = 440, - stateFlags = EPersonaStateFlag.from(2048), + state = ChatState( + friend = SteamFriend( + id = 76561198003805806, + state = EPersonaState.Online, + avatarHash = "cfc54391f2f2ba745b701ad1287f73e50dc26d74", + name = "Lossy", + nickname = "Lossy with a nickname which should clip", + gameAppID = 440, + stateFlags = EPersonaStateFlag.from(2048), + ), + messages = messages, ), - messages = List(20) { - FriendMessage( - id = it.plus(1).toLong(), - steamIDFriend = 76561198003805806, - fromLocal = it % 3 == 0, - message = "Hey!, ".repeat(it.plus(1).times(1)), - // lowPriority = false, - timestamp = 1737438789, - ) - }, onBack = { }, ) } diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt b/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt index 69039747..75f9f8f7 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt @@ -35,7 +35,7 @@ object SteamUtils { * Converts steam time to actual time * @return a string in the 'MMM d - h:mm a' format. */ - // TODO validate accuracy. + // Note: Mostly correct, has a slight skew when near another minute fun fromSteamTime(rtime: Int): String = sfd.format(rtime * 1000L) /** From c4d3719c722efc0b86aa7aab790ad2ca646f7161 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Wed, 29 Jan 2025 23:02:06 -0600 Subject: [PATCH 33/49] Refactor: Use shorthand syntax for PreviewParameterProvider values --- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 15 +++++---------- .../Pluvia/ui/screen/login/TwoFactorAuthScreen.kt | 2 +- .../Pluvia/ui/screen/login/UserLoginScreen.kt | 2 +- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index b25f6a46..c304f50e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator @@ -455,14 +456,8 @@ private fun ProfileDetailsScreen( } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -internal class FriendsScreenPreview : - PreviewParameterProvider> { - override val values: Sequence> - get() = sequenceOf( - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail), - ) +internal class FriendsScreenPreview : PreviewParameterProvider { + override val values = sequenceOf(ListDetailPaneScaffoldRole.List, ListDetailPaneScaffoldRole.Detail) } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -474,10 +469,10 @@ internal class FriendsScreenPreview : ) @Composable private fun Preview_FriendsScreenContent( - @PreviewParameter(FriendsScreenPreview::class) state: ThreePaneScaffoldDestinationItem, + @PreviewParameter(FriendsScreenPreview::class) state: ThreePaneScaffoldRole, ) { val navigator = rememberListDetailPaneScaffoldNavigator( - initialDestinationHistory = listOf(state), + initialDestinationHistory = listOf(ThreePaneScaffoldDestinationItem(state)), ) PluviaTheme { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/TwoFactorAuthScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/TwoFactorAuthScreen.kt index 4fed4789..d7960a65 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/TwoFactorAuthScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/TwoFactorAuthScreen.kt @@ -102,7 +102,7 @@ private fun TwoFactorTextField( } internal class TwoFactorPreview : PreviewParameterProvider { - override val values: Sequence = sequenceOf( + override val values = sequenceOf( UserLoginState(loginResult = LoginResult.DeviceConfirm), UserLoginState(loginResult = LoginResult.DeviceAuth), UserLoginState(loginResult = LoginResult.EmailAuth), diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/UserLoginScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/UserLoginScreen.kt index 8396b9fd..d3562283 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/UserLoginScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/login/UserLoginScreen.kt @@ -313,7 +313,7 @@ private fun UsernamePassword( } internal class UserLoginPreview : PreviewParameterProvider { - override val values: Sequence = sequenceOf( + override val values = sequenceOf( UserLoginState(isSteamConnected = true), UserLoginState(isSteamConnected = true, loginScreen = LoginScreen.QR, qrCode = "Hello World!"), UserLoginState(isSteamConnected = true, loginScreen = LoginScreen.QR, isQrFailed = true), From d2f37cfe8098516f7f7ac112c4eafa6950ada37d Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 30 Jan 2025 00:17:22 -0600 Subject: [PATCH 34/49] Implement more profile buttons. --- .../OxGames/Pluvia/service/SteamService.kt | 8 ++ .../Pluvia/service/SteamUnifiedFriends.kt | 8 +- .../Pluvia/ui/screen/chat/ChatScreen.kt | 2 +- .../ui/screen/friends/FriendProfileButton.kt | 10 +- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 100 ++++++++++++++++-- 5 files changed, 113 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 055677f5..1c3c3a22 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -1035,6 +1035,14 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun requestAliasHistory(friendID: Long) = withContext(Dispatchers.IO) { instance?.steamClient!!.getHandler()?.requestAliasHistory(SteamID(friendID)) } + + suspend fun sendTypingMessage(friendID: SteamID) = withContext(Dispatchers.IO) { + instance?._unifiedFriends!!.setIsTyping(friendID) + } + + suspend fun sendMessage(friendID: Long, message: String) = withContext(Dispatchers.IO) { + instance?._unifiedFriends!!.sendMessage(friendID, message) + } } override fun onCreate() { diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index 843619ed..a41b479f 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -211,8 +211,8 @@ class SteamUnifiedFriends( // response.body.serverTimestamp } - suspend fun sendMessage(friendID: SteamID, chatMessage: String) { - Timber.i("Sending chat message to ${friendID.convertToUInt64()}") + suspend fun sendMessage(friendID: Long, chatMessage: String) { + Timber.i("Sending chat message to $friendID") val trimmedMessage = chatMessage.trim() if (trimmedMessage.isEmpty()) { @@ -223,7 +223,7 @@ class SteamUnifiedFriends( val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { chatEntryType = EChatEntryType.ChatMsg.code() message = chatMessage - steamid = friendID.convertToUInt64() + steamid = friendID containsBbcode = true echoToSender = false lowPriority = false @@ -232,7 +232,7 @@ class SteamUnifiedFriends( val response = friendMessages!!.sendMessage(request).await() if (response.result != EResult.OK) { - Timber.w("Failed to send chat message to friend: ${friendID.convertToUInt64()}, ${response.result}") + Timber.w("Failed to send chat message to friend: $friendID, ${response.result}") return } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index cb55ec5b..9fe08624 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -346,7 +346,7 @@ internal class MessagesPreviewProvider : PreviewParameterProvider Date: Thu, 30 Jan 2025 16:55:46 -0600 Subject: [PATCH 35/49] Fold sending stickers into message sent for ChatInput. Implement sending message to the steam universe. Enable NEW_STEAM_CHAT to receive messages via unified. --- .../OxGames/Pluvia/service/SteamService.kt | 11 +- .../Pluvia/service/SteamUnifiedFriends.kt | 154 +++++++++--------- .../OxGames/Pluvia/ui/model/ChatViewModel.kt | 14 ++ .../Pluvia/ui/screen/chat/ChatInput.kt | 16 +- .../Pluvia/ui/screen/chat/ChatScreen.kt | 17 +- 5 files changed, 116 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 1c3c3a22..b6117905 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -92,6 +92,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfo import `in`.dragonbra.javasteam.steam.handlers.steamgameserver.SteamGameServer import `in`.dragonbra.javasteam.steam.handlers.steammasterserver.SteamMasterServer import `in`.dragonbra.javasteam.steam.handlers.steamscreenshots.SteamScreenshots +import `in`.dragonbra.javasteam.steam.handlers.steamuser.ChatMode import `in`.dragonbra.javasteam.steam.handlers.steamuser.LogOnDetails import `in`.dragonbra.javasteam.steam.handlers.steamuser.SteamUser import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOffCallback @@ -162,6 +163,7 @@ class SteamService : Service(), IChallengeUrlChanged { internal var callbackManager: CallbackManager? = null internal var steamClient: SteamClient? = null + internal val callbackSubscriptions: ArrayList = ArrayList() private var _unifiedFriends: SteamUnifiedFriends? = null private var _steamUser: SteamUser? = null @@ -169,8 +171,6 @@ class SteamService : Service(), IChallengeUrlChanged { private var _steamFriends: SteamFriends? = null private var _steamCloud: SteamCloud? = null - private val _callbackSubscriptions: ArrayList = ArrayList() - private var _loginResult: LoginResult = LoginResult.Failed private var retryAttempt = 0 @@ -828,6 +828,7 @@ class SteamService : Service(), IChallengeUrlChanged { // source: https://github.com/Longi94/JavaSteam/blob/08690d0aab254b44b0072ed8a4db2f86d757109b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_000_authentication/SampleLogonAuthentication.java#L146C13-L147C56 loginID = SteamUtils.getUniqueDeviceId(instance!!), machineName = SteamUtils.getMachineName(instance!!), + chatMode = ChatMode.NEW_STEAM_CHAT, ), ) } @@ -1109,7 +1110,7 @@ class SteamService : Service(), IChallengeUrlChanged { _unifiedFriends = SteamUnifiedFriends(this) // subscribe to the callbacks we are interested in - with(_callbackSubscriptions) { + with(callbackSubscriptions) { with(callbackManager!!) { add(subscribe(ConnectedCallback::class.java, ::onConnected)) add(subscribe(DisconnectedCallback::class.java, ::onDisconnected)) @@ -1232,11 +1233,11 @@ class SteamService : Service(), IChallengeUrlChanged { _steamFriends = null _steamCloud = null - for (subscription in _callbackSubscriptions) { + for (subscription in callbackSubscriptions) { subscription.close() } - _callbackSubscriptions.clear() + callbackSubscriptions.clear() callbackManager = null _unifiedFriends?.close() diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index a41b479f..4af37252 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -3,11 +3,9 @@ package com.OxGames.Pluvia.service import androidx.room.withTransaction import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.OwnedGames -import com.OxGames.Pluvia.db.dao.FriendMessagesDao -import com.OxGames.Pluvia.db.dao.SteamFriendDao import `in`.dragonbra.javasteam.enums.EChatEntryType import `in`.dragonbra.javasteam.enums.EResult -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient.CChat_RequestFriendPersonaStates_Request +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFriendmessagesSteamclient import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesPlayerSteamclient import `in`.dragonbra.javasteam.rpc.service.Chat @@ -15,8 +13,8 @@ import `in`.dragonbra.javasteam.rpc.service.FriendMessages import `in`.dragonbra.javasteam.rpc.service.FriendMessagesClient import `in`.dragonbra.javasteam.rpc.service.Player import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages +import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback.ServiceMethodNotification import `in`.dragonbra.javasteam.types.SteamID -import java.io.Closeable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -45,15 +43,6 @@ typealias IncomingMessageNotification = SteammessagesFriendmessagesSteamclient.C class SteamUnifiedFriends( private val service: SteamService, ) : AutoCloseable { - - private val messagesDao: FriendMessagesDao - get() = service.messagesDao - - private val friendDao: SteamFriendDao - get() = service.friendDao - - private val callbackSubscriptions: ArrayList = ArrayList() - private var unifiedMessages: SteamUnifiedMessages? = null private var chat: Chat? = null @@ -75,58 +64,12 @@ class SteamUnifiedFriends( friendMessages = unifiedMessages!!.createService(FriendMessages::class.java) - service.callbackManager!!.subscribeServiceNotification { - Timber.i("Ack-ing Message") - // it.body.steamidPartner - // TODO: 'read' a message since another client has opened the chat. - }.also(callbackSubscriptions::add) - - service.callbackManager!!.subscribeServiceNotification { - Timber.i("Incoming Message") - when (it.body.chatEntryType) { - EChatEntryType.Typing.code() -> { - CoroutineScope(Dispatchers.IO).launch { - service.db.withTransaction { - val friend = friendDao.findFriend(it.body.steamidFriend).first() - - if (friend == null) { - Timber.w("Unable to find friend ${it.body.steamidFriend}") - return@withTransaction - } - - friendDao.update(friend.copy(isTyping = true)) - } - } - } - - EChatEntryType.ChatMsg.code() -> { - CoroutineScope(Dispatchers.IO).launch { - service.db.withTransaction { - val friend = friendDao.findFriend(it.body.steamidFriend).first() - - if (friend == null) { - Timber.w("Unable to find friend ${it.body.steamidFriend}") - return@withTransaction - } - - friendDao.update(friend.copy(isTyping = false)) - - val chatMsg = FriendMessage( - steamIDFriend = it.body.steamidFriend, - fromLocal = false, - message = it.body.message, - timestamp = it.body.rtime32ServerTimestamp, - // lowPriority = it.body.lowPriority, - ) - - messagesDao.insertMessage(chatMsg) - } - } - } - - else -> Timber.w("Unknown incoming message, ${EChatEntryType.from(it.body.chatEntryType)}") + with(service.callbackManager!!) { + with(service.callbackSubscriptions) { + add(subscribeServiceNotification(::onIncomingMessage)) + add(subscribeServiceNotification(::onAckMessage)) } - }.also(callbackSubscriptions::add) + } } override fun close() { @@ -134,17 +77,13 @@ class SteamUnifiedFriends( chat = null player = null friendMessages = null - - callbackSubscriptions.forEach { - it.close() - } } /** * Request a fresh state of Friend's PersonaStates */ fun refreshPersonaStates() { - val request = CChat_RequestFriendPersonaStates_Request.newBuilder().build() + val request = SteammessagesChatSteamclient.CChat_RequestFriendPersonaStates_Request.newBuilder().build() chat?.requestFriendPersonaStates(request) } @@ -187,7 +126,7 @@ class SteamUnifiedFriends( } service.db.withTransaction { - messagesDao.insertMessagesIfNotExist(messages) + service.messagesDao.insertMessagesIfNotExist(messages) } Timber.i("More available: ${response.body.moreAvailable}") @@ -222,7 +161,7 @@ class SteamUnifiedFriends( val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { chatEntryType = EChatEntryType.ChatMsg.code() - message = chatMessage + message = trimmedMessage steamid = friendID containsBbcode = true echoToSender = false @@ -236,10 +175,16 @@ class SteamUnifiedFriends( return } - // TODO: This, I believe returns a result with supplemental data to append to the database. - // TODO: We also need to append the message to our database - - // response.body.serverTimestamp + service.db.withTransaction { + service.messagesDao.insertMessageIfNotExists( + FriendMessage( + steamIDFriend = friendID, + fromLocal = true, + message = response.body.modifiedMessage.ifEmpty { trimmedMessage }, + timestamp = response.body.serverTimestamp, + ), + ) + } // Once chat notifications are implemented, we should clear it here as well. } @@ -351,4 +296,63 @@ class SteamUnifiedFriends( return list } + + /** + * Another steam client (logged into the same account) has opened up chat to acknowledge the message(s). + */ + private fun onAckMessage(notification: ServiceMethodNotification) { + Timber.i("Ack-ing Message") + // it.body.steamidPartner + // TODO: 'read' a message since another client has opened the chat. + } + + /** + * We're receiving information that someone is either typing a message or sent a message. + */ + private fun onIncomingMessage(notification: ServiceMethodNotification) { + Timber.i("Incoming Message") + when (notification.body.chatEntryType) { + EChatEntryType.Typing.code() -> { + CoroutineScope(Dispatchers.IO).launch { + service.db.withTransaction { + val friend = service.friendDao.findFriend(notification.body.steamidFriend).first() + + if (friend == null) { + Timber.w("Unable to find friend ${notification.body.steamidFriend}") + return@withTransaction + } + + service.friendDao.update(friend.copy(isTyping = true)) + } + } + } + + EChatEntryType.ChatMsg.code() -> { + CoroutineScope(Dispatchers.IO).launch { + service.db.withTransaction { + val friend = service.friendDao.findFriend(notification.body.steamidFriend).first() + + if (friend == null) { + Timber.w("Unable to find friend ${notification.body.steamidFriend}") + return@withTransaction + } + + service.friendDao.update(friend.copy(isTyping = false)) + + val chatMsg = FriendMessage( + steamIDFriend = notification.body.steamidFriend, + fromLocal = false, + message = notification.body.message, + timestamp = notification.body.rtime32ServerTimestamp, + // lowPriority = it.body.lowPriority, + ) + + service.messagesDao.insertMessage(chatMsg) + } + } + } + + else -> Timber.w("Unknown incoming message, ${EChatEntryType.from(notification.body.chatEntryType)}") + } + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt index c13a66f3..079d5d29 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt @@ -8,6 +8,7 @@ import com.OxGames.Pluvia.db.dao.SteamFriendDao import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.data.ChatState import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.dragonbra.javasteam.types.SteamID import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -74,4 +75,17 @@ class ChatViewModel @Inject constructor( } } } + + fun onSendMessage(message: String) { + viewModelScope.launch { + with(_chatState.value.friend) { + if (!SteamID(id).isValid) { + Timber.w("Friend ID invalid, not sending message") + return@launch + } + + SteamService.sendMessage(id, message) + } + } + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt index 5e8ac4eb..b7811286 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt @@ -91,8 +91,7 @@ enum class EmojiStickerSelector { fun ChatInput( modifier: Modifier = Modifier, onMessageSent: (String) -> Unit, - onSticker: (String) -> Unit, - resetScroll: () -> Unit = {}, + onResetScroll: () -> Unit = {}, ) { var isEmoticonsShowing by rememberSaveable { mutableStateOf(EmojiStickerSelector.NONE) } val dismissKeyboard = { isEmoticonsShowing = EmojiStickerSelector.NONE } @@ -120,7 +119,7 @@ fun ChatInput( onTextFieldFocused = { focused -> if (focused) { isEmoticonsShowing = EmojiStickerSelector.NONE - resetScroll() + onResetScroll() } textFieldFocusState = focused }, @@ -129,7 +128,7 @@ fun ChatInput( // Reset text field and close keyboard textState = TextFieldValue() // Move scroll to bottom - resetScroll() + onResetScroll() }, isEmoticonShowing = isEmoticonsShowing, onEmoticonClick = { @@ -143,8 +142,11 @@ fun ChatInput( SelectorExpanded( isEmoticonsShowing = isEmoticonsShowing, - onTextAdded = { textState = textState.addText(it) }, - onStickerAdded = onSticker, + onTextAdded = { textState = textState.addText(":$it: ") }, + onStickerAdded = { + onMessageSent("/sticker $it") + onResetScroll() + }, ) } } @@ -458,7 +460,7 @@ fun Preview_ChatInput() { .fillMaxSize(), ) { Box(modifier = Modifier.weight(1f)) - ChatInput(onMessageSent = {}, onSticker = {}) + ChatInput(onMessageSent = {}) } } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 9fe08624..26bc7264 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -98,14 +98,15 @@ fun ChatScreen( ChatScreenContent( state = state, onBack = onBack, + onSendMessage = viewModel::onSendMessage, ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatScreenContent( state: ChatState, onBack: () -> Unit, + onSendMessage: (String) -> Unit, ) { val snackbarHost = remember { SnackbarHostState() } val scrollState = rememberLazyListState() @@ -147,14 +148,11 @@ private fun ChatScreenContent( modifier = Modifier .navigationBarsPadding() .imePadding(), - onMessageSent = { - // TODO - }, - onSticker = { - // TODO - }, - resetScroll = { - // TODO + onMessageSent = onSendMessage, + onResetScroll = { + scope.launch { + scrollState.animateScrollToItem(0) + } }, ) } @@ -375,6 +373,7 @@ private fun Preview_ChatScreenContent( messages = messages, ), onBack = { }, + onSendMessage = { }, ) } } From 08f574aec5743279ae5743d2b1f740ebfa19cbd0 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 30 Jan 2025 17:56:45 -0600 Subject: [PATCH 36/49] Move and remove some TODOs. Rename some methods in SteamFriendDao. Implement saving collapse friend list header options. --- .../java/com/OxGames/Pluvia/PrefManager.kt | 11 +++++ .../OxGames/Pluvia/db/dao/SteamFriendDao.kt | 9 ++-- .../OxGames/Pluvia/service/SteamService.kt | 13 +++--- .../Pluvia/service/SteamUnifiedFriends.kt | 42 +++++++++---------- .../java/com/OxGames/Pluvia/ui/PluviaMain.kt | 1 - .../OxGames/Pluvia/ui/component/BBCodeText.kt | 2 + .../OxGames/Pluvia/ui/data/FriendsState.kt | 3 +- .../OxGames/Pluvia/ui/model/ChatViewModel.kt | 2 +- .../Pluvia/ui/model/FriendsViewModel.kt | 6 ++- 9 files changed, 53 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt index db0b7cc8..c3266715 100644 --- a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt +++ b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json import timber.log.Timber /** @@ -383,4 +384,14 @@ object PrefManager { set(value) { setPref(START_SCREEN, value.ordinal) } + + private val FRIENDS_LIST_HEADER = stringPreferencesKey("friends_list_header") + var friendsListHeader: Set + get() { + val value = getPref(FRIENDS_LIST_HEADER, "[]") + return Json.decodeFromString>(value) + } + set(value) { + setPref(FRIENDS_LIST_HEADER, Json.encodeToString(value)) + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt b/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt index f69a45bb..c3bb7eb6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt +++ b/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt @@ -36,11 +36,14 @@ interface SteamFriendDao { suspend fun clearAllNicknames() @Query("SELECT * FROM steam_friend ORDER BY name ASC") - fun getAllFriends(): Flow> + fun getAllFriendsFlow(): Flow> @Query("SELECT * FROM steam_friend WHERE id = :id") - fun findFriend(id: Long): Flow + fun findFriendFlow(id: Long): Flow + + @Query("SELECT * FROM steam_friend WHERE id = :id") + fun findFriend(id: Long): SteamFriend? @Query("SELECT * FROM steam_friend WHERE name LIKE '%' || :name || '%' OR nickname LIKE '%' || :name || '%'") - fun findFriend(name: String): Flow> + fun findFriendFlow(name: String): Flow> } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index b6117905..24a93150 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -127,7 +127,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first import kotlinx.coroutines.future.await import kotlinx.coroutines.isActive import kotlinx.coroutines.job @@ -258,7 +257,7 @@ class SteamService : Service(), IChallengeUrlChanged { } suspend fun getPersonaStateOf(steamId: SteamID): SteamFriend? = withContext(Dispatchers.IO) { - instance!!.db.steamFriendDao().findFriend(steamId.convertToUInt64()).first() + instance!!.db.steamFriendDao().findFriend(steamId.convertToUInt64()) } fun getAppList(filter: EnumSet): List { @@ -1037,7 +1036,7 @@ class SteamService : Service(), IChallengeUrlChanged { instance?.steamClient!!.getHandler()?.requestAliasHistory(SteamID(friendID)) } - suspend fun sendTypingMessage(friendID: SteamID) = withContext(Dispatchers.IO) { + suspend fun sendTypingMessage(friendID: Long) = withContext(Dispatchers.IO) { instance?._unifiedFriends!!.setIsTyping(friendID) } @@ -1404,7 +1403,7 @@ class SteamService : Service(), IChallengeUrlChanged { } .forEach { filteredFriend -> val friendId = filteredFriend.steamID.convertToUInt64() - val friend = friendDao.findFriend(friendId).first() + val friend = friendDao.findFriend(friendId) if (friend == null) { // Not in the DB, create them. @@ -1424,7 +1423,7 @@ class SteamService : Service(), IChallengeUrlChanged { // Add logged in account if we don't exist yet. val selfId = userSteamId!!.convertToUInt64() - val self = friendDao.findFriend(selfId).first() + val self = friendDao.findFriend(selfId) if (self == null) { friendDao.insert(SteamFriend(id = selfId)) @@ -1463,7 +1462,7 @@ class SteamService : Service(), IChallengeUrlChanged { dbScope.launch { db.withTransaction { val id = callback.friendID.convertToUInt64() - val friend = friendDao.findFriend(id).first() + val friend = friendDao.findFriend(id) if (friend == null) { Timber.w("onPersonaStateReceived: failed to find friend to update: $id") @@ -1495,7 +1494,7 @@ class SteamService : Service(), IChallengeUrlChanged { // Send off an event if we change states. if (callback.friendID == steamClient!!.steamID) { - val loggedInAccount = friendDao.findFriend(id).first() ?: return@withTransaction + val loggedInAccount = friendDao.findFriend(id) ?: return@withTransaction PluviaApp.events.emit(SteamEvent.PersonaStateReceived(loggedInAccount)) } } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index 4af37252..78aee94b 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -17,7 +17,6 @@ import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback.Ser import `in`.dragonbra.javasteam.types.SteamID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber @@ -30,12 +29,7 @@ import timber.log.Timber // https://github.com/LossyDragon/Vapulla // TODO -// ----- CHAT CHECKLIST --- -// 1. Implement chat functionality, including sending and receiving messages life. -// 2. Implement getting chat message history. -// 3. Implement rich preview -// 4. Implement Stickers and Emoticons -// 5. Implement Reactions +// Implement Reactions typealias AckMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.Builder typealias IncomingMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_IncomingMessage_Notification.Builder @@ -132,17 +126,17 @@ class SteamUnifiedFriends( Timber.i("More available: ${response.body.moreAvailable}") } - suspend fun setIsTyping(friendID: SteamID) { - Timber.i("Sending 'is typing' to ${friendID.convertToUInt64()}") + suspend fun setIsTyping(friendID: Long) { + Timber.i("Sending 'is typing' to $friendID") val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { - steamid = friendID.convertToUInt64() + steamid = friendID chatEntryType = EChatEntryType.Typing.code() }.build() val response = friendMessages!!.sendMessage(request).await() if (response.result != EResult.OK) { - Timber.w("Failed to send typing message to friend: ${friendID.convertToUInt64()}, ${response.result}") + Timber.w("Failed to send typing message to friend: $friendID, ${response.result}") return } @@ -301,24 +295,31 @@ class SteamUnifiedFriends( * Another steam client (logged into the same account) has opened up chat to acknowledge the message(s). */ private fun onAckMessage(notification: ServiceMethodNotification) { - Timber.i("Ack-ing Message") - // it.body.steamidPartner - // TODO: 'read' a message since another client has opened the chat. + val friendID = notification.body.steamidPartner + Timber.i("Ack-ing Message for $friendID") + CoroutineScope(Dispatchers.IO).launch { + service.db.withTransaction { + val friend = service.friendDao.findFriend(friendID) + friend?.let { service.friendDao.update(friend.copy(unreadMessageCount = 0)) } + } + } } /** * We're receiving information that someone is either typing a message or sent a message. */ private fun onIncomingMessage(notification: ServiceMethodNotification) { - Timber.i("Incoming Message") + val steamIDFriend = notification.body.steamidFriend + Timber.i("Incoming Message form $steamIDFriend") + when (notification.body.chatEntryType) { EChatEntryType.Typing.code() -> { CoroutineScope(Dispatchers.IO).launch { service.db.withTransaction { - val friend = service.friendDao.findFriend(notification.body.steamidFriend).first() + val friend = service.friendDao.findFriend(steamIDFriend) if (friend == null) { - Timber.w("Unable to find friend ${notification.body.steamidFriend}") + Timber.w("Unable to find friend $steamIDFriend") return@withTransaction } @@ -330,21 +331,20 @@ class SteamUnifiedFriends( EChatEntryType.ChatMsg.code() -> { CoroutineScope(Dispatchers.IO).launch { service.db.withTransaction { - val friend = service.friendDao.findFriend(notification.body.steamidFriend).first() + val friend = service.friendDao.findFriend(steamIDFriend) if (friend == null) { - Timber.w("Unable to find friend ${notification.body.steamidFriend}") + Timber.w("Unable to find friend $steamIDFriend") return@withTransaction } service.friendDao.update(friend.copy(isTyping = false)) val chatMsg = FriendMessage( - steamIDFriend = notification.body.steamidFriend, + steamIDFriend = steamIDFriend, fromLocal = false, message = notification.body.message, timestamp = notification.body.rtime32ServerTimestamp, - // lowPriority = it.body.lowPriority, ) service.messagesDao.insertMessage(chatMsg) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt index b925c3bf..285aeccc 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt @@ -105,7 +105,6 @@ fun PluviaMain( is MainViewModel.MainUiEvent.OnLogonEnded -> { when (event.result) { LoginResult.Success -> { - // TODO: add preference for first screen on login Timber.i("Navigating to library") navController.navigate(PluviaScreen.Home.route) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt index d2bebdd0..6fe5f396 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt @@ -53,6 +53,8 @@ import com.skydoves.landscapist.coil.CoilImage * See: https://steamcommunity.com/comment/ForumTopic/formattinghelp */ +// TODO web rich previews? + // private val noParsePattern = "\\[noparse]([^\\[]+)\\[/noparse]".toRegex() private val colonPattern = "\u02D0([^\u02D0]+)\u02D0".toRegex() private val emoticonPattern = "\\[emoticon]([^\\[]+)\\[/emoticon]".toRegex() diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt index a4fc7e0d..77874421 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt @@ -1,12 +1,13 @@ package com.OxGames.Pluvia.ui.data +import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.data.OwnedGames import com.OxGames.Pluvia.data.SteamFriend import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback data class FriendsState( val friendsList: Map> = emptyMap(), - val collapsedListSections: Set = emptySet(), + val collapsedListSections: Set = PrefManager.friendsListHeader, val profileFriend: SteamFriend? = null, val profileFriendInfo: ProfileInfoCallback? = null, val profileFriendGames: List = emptyList(), diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt index 079d5d29..73eafc93 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt @@ -58,7 +58,7 @@ class ChatViewModel @Inject constructor( } launch { - friendDao.findFriend(id).collect { friend -> + friendDao.findFriendFlow(id).collect { friend -> if (friend == null) { throw RuntimeException("Friend is null and cannot proceed") } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt index 810a9e87..251053b7 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt @@ -2,6 +2,7 @@ package com.OxGames.Pluvia.ui.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.db.dao.SteamFriendDao import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.data.FriendsState @@ -53,7 +54,7 @@ class FriendsViewModel @Inject constructor( } selectedFriendJob = viewModelScope.launch { - steamFriendDao.findFriend(friendID).collect { friend -> + steamFriendDao.findFriendFlow(friendID).collect { friend -> _friendsState.update { it.copy(profileFriend = friend) } } } @@ -68,13 +69,14 @@ class FriendsViewModel @Inject constructor( } else { list.add(value) } + PrefManager.friendsListHeader = list currentState.copy(collapsedListSections = list) } } private fun observeFriendList() { observeFriendListJob = viewModelScope.launch(Dispatchers.IO) { - steamFriendDao.getAllFriends().collect { friends -> + steamFriendDao.getAllFriendsFlow().collect { friends -> _friendsState.update { currentState -> val sortedList = friends .filter { it.isFriend && !it.isBlocked } From 2730a9961e262f91fb0b7032dbcf7a1fddec46d7 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 30 Jan 2025 18:11:59 -0600 Subject: [PATCH 37/49] Check off more todos. Fix FriendsScreen previews. --- app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt | 2 +- app/src/main/java/com/OxGames/Pluvia/ui/data/MainState.kt | 2 +- .../main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt | 1 - .../main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt | 2 +- .../com/OxGames/Pluvia/ui/{enums => screen}/PluviaScreen.kt | 3 +-- .../com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt | 4 ++++ app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt | 2 +- 7 files changed, 9 insertions(+), 7 deletions(-) rename app/src/main/java/com/OxGames/Pluvia/ui/{enums => screen}/PluviaScreen.kt (88%) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt index 285aeccc..be50ea5c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt @@ -39,9 +39,9 @@ import com.OxGames.Pluvia.ui.component.dialog.MessageDialog import com.OxGames.Pluvia.ui.component.dialog.state.MessageDialogState import com.OxGames.Pluvia.ui.enums.DialogType import com.OxGames.Pluvia.ui.enums.Orientation -import com.OxGames.Pluvia.ui.enums.PluviaScreen import com.OxGames.Pluvia.ui.model.MainViewModel import com.OxGames.Pluvia.ui.screen.HomeScreen +import com.OxGames.Pluvia.ui.screen.PluviaScreen import com.OxGames.Pluvia.ui.screen.chat.ChatScreen import com.OxGames.Pluvia.ui.screen.login.UserLoginScreen import com.OxGames.Pluvia.ui.screen.settings.SettingsScreen diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/MainState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/MainState.kt index 37c4610e..74481f47 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/MainState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/MainState.kt @@ -1,7 +1,7 @@ package com.OxGames.Pluvia.ui.data import com.OxGames.Pluvia.enums.AppTheme -import com.OxGames.Pluvia.ui.enums.PluviaScreen +import com.OxGames.Pluvia.ui.screen.PluviaScreen import com.materialkolor.PaletteStyle data class MainState( diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt index 251053b7..57e89c01 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt @@ -61,7 +61,6 @@ class FriendsViewModel @Inject constructor( } fun onHeaderAction(value: String) { - // TODO save value as preference & restore it _friendsState.update { currentState -> val list = currentState.collapsedListSections.toMutableSet() if (value in list) { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt index c7298e96..dbdcf7d0 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt @@ -15,7 +15,7 @@ import com.OxGames.Pluvia.events.AndroidEvent import com.OxGames.Pluvia.events.SteamEvent import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.data.MainState -import com.OxGames.Pluvia.ui.enums.PluviaScreen +import com.OxGames.Pluvia.ui.screen.PluviaScreen import com.materialkolor.PaletteStyle import com.winlator.xserver.Window import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/enums/PluviaScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/PluviaScreen.kt similarity index 88% rename from app/src/main/java/com/OxGames/Pluvia/ui/enums/PluviaScreen.kt rename to app/src/main/java/com/OxGames/Pluvia/ui/screen/PluviaScreen.kt index 95c80794..45e3b851 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/enums/PluviaScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/PluviaScreen.kt @@ -1,9 +1,8 @@ -package com.OxGames.Pluvia.ui.enums +package com.OxGames.Pluvia.ui.screen /** * Destinations for top level screens, excluding home screen destinations. */ -// TODO move out of enums sealed class PluviaScreen(val route: String) { data object LoginUser : PluviaScreen("login") data object Home : PluviaScreen("home") diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 950bd9e1..949b5875 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -87,6 +87,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowWidthSizeClass +import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.R import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.ui.component.BBCodeText @@ -555,6 +556,9 @@ internal class FriendsScreenPreview : PreviewParameterProvider(state)), ) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt b/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt index 2d5f4911..9c90ea7c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt @@ -50,7 +50,7 @@ fun EmoticonImage( image: () -> Any?, ) { CoilImage( - modifier = Modifier.size(size), // TODO may not be pixel perfect + modifier = Modifier.size(size), imageModel = image, loading = { CircularProgressIndicator() From d7d9c268b07ad39692452a56c052d9548c8bf12a Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 30 Jan 2025 20:15:57 -0600 Subject: [PATCH 38/49] Implement on typing indicator. Improve upon fake data to make previews more distinct. --- .../Pluvia/ui/internal/FakeSteamFriends.kt | 28 ++++++++ .../OxGames/Pluvia/ui/model/ChatViewModel.kt | 17 +++++ .../Pluvia/ui/screen/chat/ChatInput.kt | 14 +++- .../Pluvia/ui/screen/chat/ChatScreen.kt | 28 ++++---- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 64 +++++++++---------- 5 files changed, 102 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/OxGames/Pluvia/ui/internal/FakeSteamFriends.kt diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/internal/FakeSteamFriends.kt b/app/src/main/java/com/OxGames/Pluvia/ui/internal/FakeSteamFriends.kt new file mode 100644 index 00000000..d1de06a4 --- /dev/null +++ b/app/src/main/java/com/OxGames/Pluvia/ui/internal/FakeSteamFriends.kt @@ -0,0 +1,28 @@ +package com.OxGames.Pluvia.ui.internal + +import com.OxGames.Pluvia.data.SteamFriend +import `in`.dragonbra.javasteam.enums.EPersonaState +import kotlin.random.Random + +fun fakeSteamFriends( + id: Long = 0, + online: Boolean = true, + inGame: Boolean = true, +): List { + return List(5) { item -> + SteamFriend( + id = item + id, + name = "Friend $item", + avatarHash = when (item) { + 0 -> "eb59deb3b9282854064421f7c43f4c79bceaf6d8" + 1 -> "59d19880457012c47ea57bc29f599a4d2f663a35" + 2 -> "df3be70187c6d900600796c86963e3e3a1376deb" + 3 -> "d8ebede431682097c76492df1c2209b552c8f61b" + 4 -> "c9180f93ac892fa7d078f5946239d049e987e3b6" + else -> "" + }, + state = if (online) EPersonaState.Online else EPersonaState.Offline, + gameAppID = if (inGame) Random.nextInt(1, 1000) else 0, + ) + } +} diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt index 73eafc93..8ff6acae 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt @@ -29,6 +29,8 @@ class ChatViewModel @Inject constructor( val chatState: StateFlow = _chatState.asStateFlow() private var chatJob: Job? = null + private var typingJob: Job? = null + private var lastTypingSent = 0L override fun onCleared() { super.onCleared() @@ -76,7 +78,22 @@ class ChatViewModel @Inject constructor( } } + fun onTyping() { + val now = System.currentTimeMillis() + + if (typingJob == null || now - lastTypingSent > 15000) { + typingJob?.cancel() + typingJob = viewModelScope.launch { + SteamService.sendTypingMessage(_chatState.value.friend.id) + lastTypingSent = now + } + } + } + fun onSendMessage(message: String) { + typingJob?.cancel() + typingJob = null + viewModelScope.launch { with(_chatState.value.friend) { if (!SteamID(id).isValid) { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt index b7811286..cfe36eb1 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt @@ -91,7 +91,8 @@ enum class EmojiStickerSelector { fun ChatInput( modifier: Modifier = Modifier, onMessageSent: (String) -> Unit, - onResetScroll: () -> Unit = {}, + onTyping: () -> Unit, + onResetScroll: () -> Unit, ) { var isEmoticonsShowing by rememberSaveable { mutableStateOf(EmojiStickerSelector.NONE) } val dismissKeyboard = { isEmoticonsShowing = EmojiStickerSelector.NONE } @@ -112,7 +113,10 @@ fun ChatInput( Column(modifier = modifier) { UserInputText( textFieldValue = textState, - onTextChanged = { textState = it }, + onTextChanged = { + textState = it + onTyping() + }, // Only show the keyboard if there's no input selector and text field has focus keyboardShown = isEmoticonsShowing == EmojiStickerSelector.NONE && textFieldFocusState, // Close extended selector if text field receives focus @@ -460,7 +464,11 @@ fun Preview_ChatInput() { .fillMaxSize(), ) { Box(modifier = Modifier.weight(1f)) - ChatInput(onMessageSent = {}) + ChatInput( + onMessageSent = {}, + onTyping = {}, + onResetScroll = {}, + ) } } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 26bc7264..ed020ec8 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -74,13 +74,12 @@ import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.data.ChatState +import com.OxGames.Pluvia.ui.internal.fakeSteamFriends import com.OxGames.Pluvia.ui.model.ChatViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.ui.util.ListItemImage import com.OxGames.Pluvia.utils.SteamUtils import com.OxGames.Pluvia.utils.getAvatarURL -import `in`.dragonbra.javasteam.enums.EPersonaState -import `in`.dragonbra.javasteam.enums.EPersonaStateFlag import kotlinx.coroutines.launch @Composable @@ -98,6 +97,7 @@ fun ChatScreen( ChatScreenContent( state = state, onBack = onBack, + onTyping = viewModel::onTyping, onSendMessage = viewModel::onSendMessage, ) } @@ -106,6 +106,7 @@ fun ChatScreen( private fun ChatScreenContent( state: ChatState, onBack: () -> Unit, + onTyping: () -> Unit, onSendMessage: (String) -> Unit, ) { val snackbarHost = remember { SnackbarHostState() } @@ -149,6 +150,7 @@ private fun ChatScreenContent( .navigationBarsPadding() .imePadding(), onMessageSent = onSendMessage, + onTyping = onTyping, onResetScroll = { scope.launch { scrollState.animateScrollToItem(0) @@ -344,9 +346,16 @@ internal class MessagesPreviewProvider : PreviewParameterProvider 18) { + "[sticker type=\"Delivery Cat in a Blanket\", value=0][/sticker]" + } else { + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + """.trimIndent() + }, // lowPriority = false, - timestamp = 1737438789, + timestamp = 1737438789 + it, ) }, ) @@ -361,19 +370,12 @@ private fun Preview_ChatScreenContent( PluviaTheme { ChatScreenContent( state = ChatState( - friend = SteamFriend( - id = 76561198003805806, - state = EPersonaState.Online, - avatarHash = "cfc54391f2f2ba745b701ad1287f73e50dc26d74", - name = "Lossy", - nickname = "Lossy with a nickname which should clip", - gameAppID = 440, - stateFlags = EPersonaStateFlag.from(2048), - ), + friend = fakeSteamFriends()[1], messages = messages, ), onBack = { }, onSendMessage = { }, + onTyping = { }, ) } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 949b5875..37ac9f6b 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -96,6 +97,7 @@ import com.OxGames.Pluvia.ui.component.dialog.GamesListDialog import com.OxGames.Pluvia.ui.component.topbar.AccountButton import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.data.FriendsState +import com.OxGames.Pluvia.ui.internal.fakeSteamFriends import com.OxGames.Pluvia.ui.model.FriendsViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme import com.OxGames.Pluvia.utils.getAvatarURL @@ -103,7 +105,6 @@ import com.OxGames.Pluvia.utils.getProfileUrl import com.materialkolor.ktx.isLight import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage -import `in`.dragonbra.javasteam.enums.EPersonaState import `in`.dragonbra.javasteam.enums.EResult import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback import `in`.dragonbra.javasteam.types.SteamID @@ -564,38 +565,35 @@ private fun Preview_FriendsScreenContent( ) PluviaTheme { - FriendsScreenContent( - navigator = navigator, - state = FriendsState( - friendsList = mapOf( - "TEST A" to List(3) { SteamFriend(id = it.toLong()) }, - "TEST B" to List(3) { SteamFriend(id = it.toLong() + 5) }, - "TEST C" to List(3) { SteamFriend(id = it.toLong() + 10) }, + Surface { + FriendsScreenContent( + navigator = navigator, + state = FriendsState( + friendsList = mapOf( + "In-Game" to fakeSteamFriends(), + "Online" to fakeSteamFriends(id = 5, inGame = false), + "Offline" to fakeSteamFriends(id = 10, online = false, inGame = false), + ), + profileFriend = fakeSteamFriends()[1], + profileFriendInfo = ProfileInfoCallback( + result = EResult.OK, + steamID = SteamID(123L), + timeCreated = Date(9988776655 * 1000L), + realName = "Friend Name", + cityName = "Friend Town", + stateName = "Friend State", + countryName = "Friend Country", + headline = "", + summary = "[emoticon]roar[/emoticon] Very nice profile! ːsteamboredː ːsteamthisː", + ), ), - profileFriend = SteamFriend( - id = 123L, - nickname = "Pluvia".repeat(3).trimEnd(), - state = EPersonaState.Online, - gameName = "Left 4 Dead 2", - ), - profileFriendInfo = ProfileInfoCallback( - result = EResult.OK, - steamID = SteamID(123L), - timeCreated = Date(9988776655 * 1000L), - realName = "Pluvia", - cityName = "Pluvia Town", - stateName = "Pluviaville", - countryName = "United Pluvia", - headline = "", - summary = "A [emoticon]roar[/emoticon] Fake Summary ːsteamboredː ːsteamthisː", - ), - ), - onFriendClick = { }, - onHeaderAction = { }, - onBack = { }, - onSettings = { }, - onLogout = { }, - onChat = { }, - ) + onFriendClick = { }, + onHeaderAction = { }, + onBack = { }, + onSettings = { }, + onLogout = { }, + onChat = { }, + ) + } } } From 4ddbb9adadbb9d1268334073b6aff4d462d1e904 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 30 Jan 2025 20:28:26 -0600 Subject: [PATCH 39/49] Make feature notice a snackbar instead of a sticky header. --- .../java/com/OxGames/Pluvia/PrefManager.kt | 8 +++++ .../Pluvia/service/SteamUnifiedFriends.kt | 13 +++----- .../Pluvia/ui/screen/chat/ChatScreen.kt | 33 ++++++++++--------- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 1 - 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt index c3266715..c207faa3 100644 --- a/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt +++ b/app/src/main/java/com/OxGames/Pluvia/PrefManager.kt @@ -394,4 +394,12 @@ object PrefManager { set(value) { setPref(FRIENDS_LIST_HEADER, Json.encodeToString(value)) } + + // NOTE: This should be removed once chat is considered stable. + private val ACK_CHAT_PREVIEW = booleanPreferencesKey("ack_chat_preview") + var ackChatPreview: Boolean + get() = getPref(ACK_CHAT_PREVIEW, false) + set(value) { + setPref(ACK_CHAT_PREVIEW, value) + } } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index 78aee94b..de1f5921 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -30,6 +30,9 @@ import timber.log.Timber // TODO // Implement Reactions +// OfflineMessageNotificationCallback ? +// FriendMsgEchoCallback ? +// EmoticonListCallback ? typealias AckMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.Builder typealias IncomingMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_IncomingMessage_Notification.Builder @@ -45,10 +48,6 @@ class SteamUnifiedFriends( private var friendMessages: FriendMessages? = null - // TODO OfflineMessageNotificationCallback ? - // TODO FriendMsgEchoCallback ? - // TODO EmoticonListCallback ? - init { unifiedMessages = service.steamClient!!.getHandler() @@ -105,9 +104,6 @@ class SteamUnifiedFriends( return } - // TODO: Insert new messages into database - // TODO: Do not dupe messages - // TODO: reactions val regex = "\\[U:\\d+:(\\d+)]".toRegex() val userSteamId3 = regex.find(userSteamID.render())!!.groupValues[1].toInt() val messages = response.body.messagesList.map { message -> @@ -194,6 +190,7 @@ class SteamUnifiedFriends( friendMessages!!.ackMessage(request) } + // TODO suspend fun getActiveMessageSessions() { Timber.i("Get Active message sessions") @@ -209,8 +206,6 @@ class SteamUnifiedFriends( return } - // TODO - // response.body.timestamp response.body.messageSessionsList.forEach { session -> diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index ed020ec8..de95513d 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars @@ -43,6 +42,7 @@ import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -70,6 +70,7 @@ import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.ui.component.topbar.BackButton @@ -113,6 +114,22 @@ private fun ChatScreenContent( val scrollState = rememberLazyListState() val scope = rememberCoroutineScope() + // NOTE: This should be removed once chat is considered stable. + LaunchedEffect(Unit) { + if (!PrefManager.ackChatPreview) { + scope.launch { + val result = snackbarHost.showSnackbar( + message = "Chatting is still an early feature.\nPlease report any issues in the project repo.", + actionLabel = "OK", + ) + + if (result == SnackbarResult.ActionPerformed) { + PrefManager.ackChatPreview = true + } + } + } + } + Scaffold( // Exclude ime and navigation bar padding so this can be added by the ChatInput composable contentWindowInsets = ScaffoldDefaults @@ -180,20 +197,6 @@ private fun ChatMessages( state = scrollState, reverseLayout = true, ) { - stickyHeader { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 12.sp, - text = "Chatting is still an early feature.\n" + - "Please report any issues in the project repo.", - ) - } - } - items(state.messages, key = { it.id }) { msg -> ChatBubble( message = msg.message, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 37ac9f6b..cf36c3f2 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -78,7 +78,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview From 79c61d30d13f7c6277b63a94e7b19cca383053a4 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 30 Jan 2025 22:51:45 -0600 Subject: [PATCH 40/49] Add dialogs for friend options: block, remove, favorite, unfavorite, and set nickname. --- .../com/OxGames/Pluvia/data/SteamFriend.kt | 8 +- .../OxGames/Pluvia/db/dao/SteamFriendDao.kt | 3 + .../OxGames/Pluvia/service/SteamService.kt | 24 +++ .../Pluvia/service/SteamUnifiedFriends.kt | 50 ++++- .../com/OxGames/Pluvia/ui/enums/DialogType.kt | 5 + .../Pluvia/ui/model/FriendsViewModel.kt | 18 ++ .../Pluvia/ui/screen/friends/FriendsScreen.kt | 178 +++++++++++++++++- .../java/com/OxGames/Pluvia/ui/theme/Color.kt | 1 + 8 files changed, 274 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt index 4c8e6629..b040491c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt +++ b/app/src/main/java/com/OxGames/Pluvia/data/SteamFriend.kt @@ -13,6 +13,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import com.OxGames.Pluvia.ui.component.icons.VR import com.OxGames.Pluvia.ui.theme.friendAwayOrSnooze +import com.OxGames.Pluvia.ui.theme.friendBlocked import com.OxGames.Pluvia.ui.theme.friendInGame import com.OxGames.Pluvia.ui.theme.friendInGameAwayOrSnooze import com.OxGames.Pluvia.ui.theme.friendOffline @@ -92,7 +93,11 @@ data class SteamFriend( get() = if (isPlayingGame) { gameName.ifEmpty { "Playing game id: $gameAppID" } } else { - state.name + if (isBlocked) { + relation.name + } else { + state.name + } } val isAwayOrSnooze: Boolean @@ -116,6 +121,7 @@ data class SteamFriend( val statusColor: Color get() = when { + isBlocked -> friendBlocked isOffline -> friendOffline isInGameAwayOrSnooze -> friendInGameAwayOrSnooze isAwayOrSnooze -> friendAwayOrSnooze diff --git a/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt b/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt index c3bb7eb6..824e0e64 100644 --- a/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt +++ b/app/src/main/java/com/OxGames/Pluvia/db/dao/SteamFriendDao.kt @@ -46,4 +46,7 @@ interface SteamFriendDao { @Query("SELECT * FROM steam_friend WHERE name LIKE '%' || :name || '%' OR nickname LIKE '%' || :name || '%'") fun findFriendFlow(name: String): Flow> + + @Query("DELETE FROM steam_friend WHERE id = :friendId") + suspend fun remove(friendId: Long) } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 24a93150..26202d87 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -60,6 +60,7 @@ import com.google.android.play.core.splitinstall.SplitInstallManagerFactory import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus import com.winlator.xenvironment.ImageFs import dagger.hilt.android.AndroidEntryPoint +import `in`.dragonbra.javasteam.enums.EFriendRelationship import `in`.dragonbra.javasteam.enums.ELicenseType import `in`.dragonbra.javasteam.enums.EOSType import `in`.dragonbra.javasteam.enums.EPaymentMethod @@ -1043,6 +1044,29 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun sendMessage(friendID: Long, message: String) = withContext(Dispatchers.IO) { instance?._unifiedFriends!!.sendMessage(friendID, message) } + + suspend fun blockFriend(friendID: Long) = withContext(Dispatchers.IO) { + val friend = SteamID(friendID) + val result = instance?._steamFriends!!.ignoreFriend(friend).await() + + if (result.result == EResult.OK) { + val blockedFriend = instance!!.friendDao.findFriend(friendID) + blockedFriend?.let { + instance?.friendDao!!.update(it.copy(relation = EFriendRelationship.Blocked)) + } + } + } + + suspend fun removeFriend(friendID: Long) = withContext(Dispatchers.IO) { + val friend = SteamID(friendID) + instance?._steamFriends!!.removeFriend(friend) + instance?.friendDao!!.remove(friendID) + } + + suspend fun setNickName(friendID: Long, value: String) = withContext(Dispatchers.IO) { + val friend = SteamID(friendID) + instance?._steamFriends!!.setFriendNickname(friend, value) + } } override fun onCreate() { diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index de1f5921..ba706719 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -3,8 +3,10 @@ package com.OxGames.Pluvia.service import androidx.room.withTransaction import com.OxGames.Pluvia.data.FriendMessage import com.OxGames.Pluvia.data.OwnedGames +import `in`.dragonbra.javasteam.enums.EAccountType import `in`.dragonbra.javasteam.enums.EChatEntryType import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.enums.EUniverse import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFriendmessagesSteamclient import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesPlayerSteamclient @@ -12,6 +14,7 @@ import `in`.dragonbra.javasteam.rpc.service.Chat import `in`.dragonbra.javasteam.rpc.service.FriendMessages import `in`.dragonbra.javasteam.rpc.service.FriendMessagesClient import `in`.dragonbra.javasteam.rpc.service.Player +import `in`.dragonbra.javasteam.rpc.service.PlayerClient import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback.ServiceMethodNotification import `in`.dragonbra.javasteam.types.SteamID @@ -20,11 +23,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber -// For chat ideas, check out: +// References: // https://github.com/marwaniaaj/RichLinksJetpackCompose/tree/main // https://blog.stackademic.com/rick-link-representation-in-jetpack-compose-d33956e8719e // https://github.com/lukasroberts/AndroidLinkView -// https://github.com/Aldikitta/JetComposeChatWithMe // https://github.com/android/compose-samples/tree/main/Jetchat // https://github.com/LossyDragon/Vapulla @@ -36,10 +38,12 @@ import timber.log.Timber typealias AckMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.Builder typealias IncomingMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_IncomingMessage_Notification.Builder +typealias FriendNicknameChanged = SteammessagesPlayerSteamclient.CPlayer_FriendNicknameChanged_Notification.Builder class SteamUnifiedFriends( private val service: SteamService, ) : AutoCloseable { + private var unifiedMessages: SteamUnifiedMessages? = null private var chat: Chat? = null @@ -61,6 +65,7 @@ class SteamUnifiedFriends( with(service.callbackSubscriptions) { add(subscribeServiceNotification(::onIncomingMessage)) add(subscribeServiceNotification(::onAckMessage)) + add(subscribeServiceNotification(::onNickNameChanged)) } } } @@ -80,6 +85,9 @@ class SteamUnifiedFriends( chat?.requestFriendPersonaStates(request) } + /** + * Gets the last 50 messages from the specified friend. Steam may not provide all 50. + */ suspend fun getRecentMessages(friendID: Long) { Timber.i("Getting Recent messages for: $friendID") @@ -122,6 +130,9 @@ class SteamUnifiedFriends( Timber.i("More available: ${response.body.moreAvailable}") } + /** + * Sends a 'is typing' message to the specified friend. + */ suspend fun setIsTyping(friendID: Long) { Timber.i("Sending 'is typing' to $friendID") val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { @@ -140,6 +151,9 @@ class SteamUnifiedFriends( // response.body.serverTimestamp } + /** + * Sends a chat message to the specified friend. + */ suspend fun sendMessage(friendID: Long, chatMessage: String) { Timber.i("Sending chat message to $friendID") val trimmedMessage = chatMessage.trim() @@ -179,6 +193,9 @@ class SteamUnifiedFriends( // Once chat notifications are implemented, we should clear it here as well. } + /** + * Acknowledge the message, this will mark other clients that we have read the message. + */ fun ackMessage(friendID: Long) { Timber.d("Ack-ing message for friend: $friendID") val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.newBuilder().apply { @@ -190,7 +207,9 @@ class SteamUnifiedFriends( friendMessages!!.ackMessage(request) } - // TODO + /** + * TODO + */ suspend fun getActiveMessageSessions() { Timber.i("Get Active message sessions") @@ -216,8 +235,14 @@ class SteamUnifiedFriends( } } + /** + * TODO + */ // suspend fun getPerFriendPreferences() + /** + * TODO + */ suspend fun updateMessageReaction( friendID: SteamID, serverTimestamp: Int, @@ -251,6 +276,9 @@ class SteamUnifiedFriends( } } + /** + * Gets a list of games that the user owns. If the library is private, it will be empty. + */ suspend fun getOwnedGames(steamID: Long): List { val request = SteammessagesPlayerSteamclient.CPlayer_GetOwnedGames_Request.newBuilder().apply { steamid = steamID @@ -300,6 +328,22 @@ class SteamUnifiedFriends( } } + /** + * Someone has changed their nickname. + */ + private fun onNickNameChanged(notification: ServiceMethodNotification) { + CoroutineScope(Dispatchers.IO).launch { + Timber.i("Nickname Changed for ${notification.body.accountid} -> ${notification.body.nickname}") + val friendID = SteamID(notification.body.accountid.toLong(), EUniverse.Public, EAccountType.Individual) + + service.db.withTransaction { + service.friendDao.findFriend(friendID.convertToUInt64())?.let { + service.friendDao.update(it.copy(nickname = notification.body.nickname)) + } + } + } + } + /** * We're receiving information that someone is either typing a message or sent a message. */ diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt b/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt index dd72d977..ac45ca81 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt @@ -19,4 +19,9 @@ enum class DialogType { INSTALL_IMAGEFS, NONE, + + FRIEND_BLOCK, + FRIEND_REMOVE, + FRIEND_FAVORITE, + FRIEND_UN_FAVORITE, } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt index 57e89c01..d71f7af6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt @@ -73,6 +73,24 @@ class FriendsViewModel @Inject constructor( } } + fun onBlock(friendID: Long) { + viewModelScope.launch { + SteamService.blockFriend(friendID) + } + } + + fun onRemove(friendID: Long) { + viewModelScope.launch { + SteamService.removeFriend(friendID) + } + } + + fun onNickName(value: String) { + viewModelScope.launch { + SteamService.setNickName(_friendsState.value.profileFriend!!.id, value) + } + } + private fun observeFriendList() { observeFriendListJob = viewModelScope.launch(Dispatchers.IO) { steamFriendDao.getAllFriendsFlow().collect { friends -> diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index cf36c3f2..3fb60f12 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -30,6 +30,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Favorite @@ -40,6 +44,7 @@ import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.PersonOff import androidx.compose.material.icons.outlined.PersonRemove +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -49,11 +54,13 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AnimatedPane @@ -93,9 +100,12 @@ import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.ui.component.BBCodeText import com.OxGames.Pluvia.ui.component.LoadingScreen import com.OxGames.Pluvia.ui.component.dialog.GamesListDialog +import com.OxGames.Pluvia.ui.component.dialog.MessageDialog +import com.OxGames.Pluvia.ui.component.dialog.state.MessageDialogState import com.OxGames.Pluvia.ui.component.topbar.AccountButton import com.OxGames.Pluvia.ui.component.topbar.BackButton import com.OxGames.Pluvia.ui.data.FriendsState +import com.OxGames.Pluvia.ui.enums.DialogType import com.OxGames.Pluvia.ui.internal.fakeSteamFriends import com.OxGames.Pluvia.ui.model.FriendsViewModel import com.OxGames.Pluvia.ui.theme.PluviaTheme @@ -127,9 +137,12 @@ fun FriendsScreen( state = state, onBack = { onBackPressedDispatcher?.onBackPressed() }, onChat = onChat, + onBlock = viewModel::onBlock, + onRemove = viewModel::onRemove, onFriendClick = viewModel::observeSelectedFriend, onHeaderAction = viewModel::onHeaderAction, onLogout = onLogout, + onNickName = viewModel::onNickName, onSettings = onSettings, ) } @@ -141,9 +154,12 @@ private fun FriendsScreenContent( state: FriendsState, onBack: () -> Unit, onChat: (Long) -> Unit, + onBlock: (Long) -> Unit, + onRemove: (Long) -> Unit, onFriendClick: (Long) -> Unit, onHeaderAction: (String) -> Unit, onLogout: () -> Unit, + onNickName: (String) -> Unit, onSettings: () -> Unit, ) { val listState = rememberLazyListState() // Hoisted high to preserve state @@ -192,6 +208,9 @@ private fun FriendsScreenContent( state = state, onBack = onBack, onChat = onChat, + onBlock = onBlock, + onRemove = onRemove, + onNickName = onNickName, onShowGames = { showGamesDialog = true }, @@ -270,6 +289,9 @@ private fun FriendsDetailPane( state: FriendsState, onBack: () -> Unit, onChat: (Long) -> Unit, + onBlock: (Long) -> Unit, + onRemove: (Long) -> Unit, + onNickName: (String) -> Unit, onShowGames: () -> Unit, ) { Surface { @@ -284,6 +306,9 @@ private fun FriendsDetailPane( state = state, onBack = onBack, onChat = onChat, + onBlock = onBlock, + onRemove = onRemove, + onNickName = onNickName, onShowGames = onShowGames, ) } @@ -313,10 +338,120 @@ private fun ProfileDetailsScreen( state: FriendsState, onBack: () -> Unit, onChat: (Long) -> Unit, + onBlock: (Long) -> Unit, + onRemove: (Long) -> Unit, + onNickName: (String) -> Unit, onShowGames: () -> Unit, ) { val scrollState = rememberScrollState() val windowWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + val context = LocalContext.current + + var msgDialogState by rememberSaveable(stateSaver = MessageDialogState.Saver) { + mutableStateOf(MessageDialogState(false)) + } + + val onDismissRequest: (() -> Unit)? + val onDismissClick: (() -> Unit)? + val onConfirmClick: (() -> Unit)? + + when (msgDialogState.type) { + DialogType.FRIEND_BLOCK -> { + onConfirmClick = { + onBlock(state.profileFriend!!.id) + msgDialogState = MessageDialogState(visible = false) + } + onDismissRequest = { msgDialogState = MessageDialogState(visible = false) } + onDismissClick = { msgDialogState = MessageDialogState(visible = false) } + } + + DialogType.FRIEND_REMOVE -> { + onConfirmClick = { + onRemove(state.profileFriend!!.id) + msgDialogState = MessageDialogState(visible = false) + } + onDismissRequest = { msgDialogState = MessageDialogState(visible = false) } + onDismissClick = { msgDialogState = MessageDialogState(visible = false) } + } + + DialogType.FRIEND_FAVORITE -> { + onConfirmClick = { + Toast.makeText(context, "Favorite TODO", Toast.LENGTH_SHORT).show() + msgDialogState = MessageDialogState(visible = false) + } + onDismissRequest = { msgDialogState = MessageDialogState(visible = false) } + onDismissClick = { msgDialogState = MessageDialogState(visible = false) } + } + + DialogType.FRIEND_UN_FAVORITE -> { + onConfirmClick = { + Toast.makeText(context, "Un-Favorite TODO", Toast.LENGTH_SHORT).show() + msgDialogState = MessageDialogState(visible = false) + } + onDismissRequest = { msgDialogState = MessageDialogState(visible = false) } + onDismissClick = { msgDialogState = MessageDialogState(visible = false) } + } + + else -> { + onDismissRequest = null + onDismissClick = null + onConfirmClick = null + } + } + + MessageDialog( + visible = msgDialogState.visible, + onDismissRequest = onDismissRequest, + onConfirmClick = onConfirmClick, + confirmBtnText = msgDialogState.confirmBtnText, + onDismissClick = onDismissClick, + dismissBtnText = msgDialogState.dismissBtnText, + icon = msgDialogState.icon, + title = msgDialogState.title, + message = msgDialogState.message, + ) + + var setNickNameDialog by rememberSaveable { mutableStateOf(false) } + var newNickName by rememberSaveable(state.profileFriend!!.nickname) { + mutableStateOf(state.profileFriend.nickname) + } + if (setNickNameDialog) { + AlertDialog( + onDismissRequest = { + setNickNameDialog = false + }, + icon = { Icon(imageVector = Icons.Default.Edit, contentDescription = null) }, + title = { Text(text = "Set Nickname") }, + text = { + Column { + Text(text = "Set a new nickname for ${state.profileFriend!!.name}") + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = newNickName, + onValueChange = { newNickName = it }, + label = { Text(text = "Nickname") }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + setNickNameDialog = false + onNickName(newNickName) + }, + content = { Text(text = "Confirm") }, + ) + }, + dismissButton = { + TextButton( + onClick = { + setNickNameDialog = false + }, + content = { Text(text = "Cancel") }, + ) + }, + ) + } Scaffold( topBar = { @@ -338,7 +473,6 @@ private fun ProfileDetailsScreen( }, ) { paddingValues -> val uriHandler = LocalUriHandler.current - val context = LocalContext.current val isLight = MaterialTheme.colorScheme.background.isLight() var moreExpanded by rememberSaveable { mutableStateOf(false) } @@ -449,8 +583,7 @@ private fun ProfileDetailsScreen( icon = Icons.Outlined.Edit, text = "Set Nickname", onClick = { - // TODO - Toast.makeText(context, "Nickname TODO", Toast.LENGTH_SHORT).show() + setNickNameDialog = true }, ) Spacer(modifier = Modifier.width(16.dp)) @@ -458,8 +591,16 @@ private fun ProfileDetailsScreen( icon = Icons.Outlined.PersonOff, text = "Block Friend", onClick = { - // TODO - Toast.makeText(context, "Block TODO", Toast.LENGTH_SHORT).show() + msgDialogState = MessageDialogState( + visible = true, + type = DialogType.FRIEND_BLOCK, + confirmBtnText = "Block", + dismissBtnText = "Cancel", + icon = Icons.Default.Block, + title = "Block Friend", + message = "Are you sure you want to block ${state.profileFriend.nameOrNickname}?\n" + + "This will block them on all steam clients.", + ) }, ) Spacer(modifier = Modifier.width(16.dp)) @@ -467,8 +608,16 @@ private fun ProfileDetailsScreen( icon = Icons.Outlined.PersonRemove, text = "Remove Friend", onClick = { - // TODO - Toast.makeText(context, "Remove TODO", Toast.LENGTH_SHORT).show() + msgDialogState = MessageDialogState( + visible = true, + type = DialogType.FRIEND_REMOVE, + confirmBtnText = "Remove", + dismissBtnText = "Cancel", + icon = Icons.Default.PersonRemove, + title = "Remove Friend", + message = "Are you sure you want to remove ${state.profileFriend.nameOrNickname}?\n" + + "This will remove them on all steam clients.", + ) }, ) } @@ -484,8 +633,16 @@ private fun ProfileDetailsScreen( icon = Icons.Outlined.Favorite, text = "Add to Favorites", onClick = { - // TODO - Toast.makeText(context, "Favorites TODO", Toast.LENGTH_SHORT).show() + msgDialogState = MessageDialogState( + visible = true, + type = DialogType.FRIEND_FAVORITE, + confirmBtnText = "Favorite", + dismissBtnText = "Cancel", + icon = Icons.Default.Favorite, + title = "Favorite Friend", + message = "Are you sure you want to favorite ${state.profileFriend.nameOrNickname}?\n" + + "This will favorite them on all steam clients.", + ) }, ) Spacer(modifier = Modifier.width(16.dp)) @@ -592,6 +749,9 @@ private fun Preview_FriendsScreenContent( onSettings = { }, onLogout = { }, onChat = { }, + onBlock = { }, + onRemove = { }, + onNickName = { }, ) } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt b/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt index c980e77d..61369bec 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/theme/Color.kt @@ -14,6 +14,7 @@ val friendInGame = Color(0xFF90BA3C) val friendInGameAwayOrSnooze = Color(0x8090BA3C) val friendOffline = Color(0xFF7A7A7A) val friendOnline = Color(0xFF6DCFF6) +val friendBlocked = Color(0xFF983D3D) /** * Alorma compose settings tile colors From 3539eb36818fd90ac6a38a8809b442f2ef9f9412 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Thu, 30 Jan 2025 23:56:16 -0600 Subject: [PATCH 41/49] Hoist the chat scroll state to the viewmodel. Fix scroll to bottom if a new message appears. Implement alias history dialog. --- .../com/OxGames/Pluvia/events/SteamEvent.kt | 3 +- .../OxGames/Pluvia/service/SteamService.kt | 7 ++- .../OxGames/Pluvia/ui/data/FriendsState.kt | 1 + .../OxGames/Pluvia/ui/model/ChatViewModel.kt | 7 +++ .../Pluvia/ui/model/FriendsViewModel.kt | 26 +++++++++- .../Pluvia/ui/screen/chat/ChatScreen.kt | 11 ++++- .../Pluvia/ui/screen/friends/FriendItem.kt | 2 + .../ui/screen/friends/FriendStickyHeader.kt | 2 +- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 49 +++++++++++++++++-- 9 files changed, 99 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt index da7e0bb5..af849c89 100644 --- a/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt +++ b/app/src/main/java/com/OxGames/Pluvia/events/SteamEvent.kt @@ -2,7 +2,6 @@ package com.OxGames.Pluvia.events import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.enums.LoginResult -import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.AliasHistoryCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback sealed interface SteamEvent : Event { @@ -20,5 +19,5 @@ sealed interface SteamEvent : Event { // This isn't a SteamEvent, but since its the only one now, it can stay data class OnProfileInfo(val info: ProfileInfoCallback) : SteamEvent - data class OnAliasHistory(val info: AliasHistoryCallback) : SteamEvent + data class OnAliasHistory(val names: List) : SteamEvent } diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt index 26202d87..dc7fccd1 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamService.kt @@ -1145,7 +1145,7 @@ class SteamService : Service(), IChallengeUrlChanged { add(subscribe(NicknameListCallback::class.java, ::onNicknameList)) add(subscribe(FriendsListCallback::class.java, ::onFriendsList)) add(subscribe(EmoticonListCallback::class.java, ::onEmoticonList)) - add(subscribe(AliasHistoryCallback::class.java) { PluviaApp.events.emit(SteamEvent.OnAliasHistory(it)) }) + add(subscribe(AliasHistoryCallback::class.java, ::onAliasHistory)) } } @@ -1469,6 +1469,11 @@ class SteamService : Service(), IChallengeUrlChanged { } } + private fun onAliasHistory(callback: AliasHistoryCallback) { + val names = callback.responses.flatMap { map -> map.names }.map { map -> map.name } + PluviaApp.events.emit(SteamEvent.OnAliasHistory(names)) + } + @OptIn(ExperimentalStdlibApi::class) private fun onPersonaStateReceived(callback: PersonaStatesCallback) { // Ignore accounts that arent individuals diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt index 77874421..ccf328d7 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/FriendsState.kt @@ -11,4 +11,5 @@ data class FriendsState( val profileFriend: SteamFriend? = null, val profileFriendInfo: ProfileInfoCallback? = null, val profileFriendGames: List = emptyList(), + val profileFriendAlias: List = emptyList(), ) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt index 8ff6acae..02b436c3 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/ChatViewModel.kt @@ -1,5 +1,9 @@ package com.OxGames.Pluvia.ui.model +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.OxGames.Pluvia.db.dao.EmoticonDao @@ -28,6 +32,9 @@ class ChatViewModel @Inject constructor( private val _chatState = MutableStateFlow(ChatState()) val chatState: StateFlow = _chatState.asStateFlow() + // Keep the chat scroll state. This will last longer as the VM will stay alive. + var listState: LazyListState by mutableStateOf(LazyListState(0, 0)) + private var chatJob: Job? = null private var typingJob: Job? = null private var lastTypingSent = 0L diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt index d71f7af6..5209bd67 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt @@ -2,8 +2,10 @@ package com.OxGames.Pluvia.ui.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.OxGames.Pluvia.PluviaApp import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.db.dao.SteamFriendDao +import com.OxGames.Pluvia.events.SteamEvent import com.OxGames.Pluvia.service.SteamService import com.OxGames.Pluvia.ui.data.FriendsState import dagger.hilt.android.lifecycle.HiltViewModel @@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import timber.log.Timber @HiltViewModel class FriendsViewModel @Inject constructor( @@ -28,20 +31,35 @@ class FriendsViewModel @Inject constructor( private var selectedFriendJob: Job? = null private var observeFriendListJob: Job? = null + private val onAliasHistory: (SteamEvent.OnAliasHistory) -> Unit = { + _friendsState.update { currentState -> currentState.copy(profileFriendAlias = it.names) } + } + init { observeFriendList() + PluviaApp.events.on(onAliasHistory) } override fun onCleared() { + Timber.d("onCleared") + selectedFriendJob?.cancel() observeFriendListJob?.cancel() + PluviaApp.events.off(onAliasHistory) } fun observeSelectedFriend(friendID: Long) { selectedFriendJob?.cancel() // Force clear states when this method if is called again. - _friendsState.update { it.copy(profileFriend = null, profileFriendGames = emptyList(), profileFriendInfo = null) } + _friendsState.update { + it.copy( + profileFriend = null, + profileFriendGames = emptyList(), + profileFriendInfo = null, + profileFriendAlias = emptyList(), + ) + } viewModelScope.launch { val resp = SteamService.getProfileInfo(SteamID(friendID)) @@ -91,6 +109,12 @@ class FriendsViewModel @Inject constructor( } } + fun onAlias() { + viewModelScope.launch { + SteamService.requestAliasHistory(_friendsState.value.profileFriend!!.id) + } + } + private fun observeFriendList() { observeFriendListJob = viewModelScope.launch(Dispatchers.IO) { steamFriendDao.getAllFriendsFlow().collect { friends -> diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index de95513d..541c03e8 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -97,6 +97,7 @@ fun ChatScreen( ChatScreenContent( state = state, + scrollState = viewModel.listState, onBack = onBack, onTyping = viewModel::onTyping, onSendMessage = viewModel::onSendMessage, @@ -106,12 +107,12 @@ fun ChatScreen( @Composable private fun ChatScreenContent( state: ChatState, + scrollState: LazyListState, onBack: () -> Unit, onTyping: () -> Unit, onSendMessage: (String) -> Unit, ) { val snackbarHost = remember { SnackbarHostState() } - val scrollState = rememberLazyListState() val scope = rememberCoroutineScope() // NOTE: This should be removed once chat is considered stable. @@ -169,6 +170,7 @@ private fun ChatScreenContent( onMessageSent = onSendMessage, onTyping = onTyping, onResetScroll = { + // Scroll to the bottom of the list regardless of the scroll state scope.launch { scrollState.animateScrollToItem(0) } @@ -215,6 +217,12 @@ private fun ChatMessages( } } + LaunchedEffect(state.messages) { + if (!jumpToBottomButtonEnabled) { + scrollState.animateScrollToItem(0) + } + } + AnimatedVisibility( modifier = Modifier .align(Alignment.BottomEnd) @@ -376,6 +384,7 @@ private fun Preview_ChatScreenContent( friend = fakeSteamFriends()[1], messages = messages, ), + scrollState = rememberLazyListState(), onBack = { }, onSendMessage = { }, onTyping = { }, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt index 2a2455bb..0812c09e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt @@ -1,6 +1,7 @@ package com.OxGames.Pluvia.ui.screen.friends import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.text.InlineTextContent @@ -32,6 +33,7 @@ import `in`.dragonbra.javasteam.enums.EPersonaStateFlag // https://m3.material.io/components/lists/specs#d156b3f2-6763-4fde-ba6f-0f088ce5a4e4 +@OptIn(ExperimentalFoundationApi::class) @Composable fun FriendItem( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt index 2a39b005..391e8242 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt @@ -42,7 +42,7 @@ private fun Preview_StickyHeaderItem() { id = 0, state = EPersonaState.Online, gameAppID = 440, - gameName = "Team Fortess 2", + gameName = "Team Fortress 2", name = "Name The Game", ), onClick = { }, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 3fb60f12..6e3b2be6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -22,6 +23,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState @@ -33,6 +35,7 @@ import androidx.compose.material.icons.automirrored.outlined.Chat import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.outlined.Edit @@ -144,6 +147,7 @@ fun FriendsScreen( onLogout = onLogout, onNickName = viewModel::onNickName, onSettings = onSettings, + onAlias = viewModel::onAlias, ) } @@ -161,6 +165,7 @@ private fun FriendsScreenContent( onLogout: () -> Unit, onNickName: (String) -> Unit, onSettings: () -> Unit, + onAlias: () -> Unit, ) { val listState = rememberLazyListState() // Hoisted high to preserve state val snackbarHost = remember { SnackbarHostState() } @@ -214,13 +219,14 @@ private fun FriendsScreenContent( onShowGames = { showGamesDialog = true }, + onAlias = onAlias, ) } }, ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun FriendsListPane( state: FriendsState, @@ -293,6 +299,7 @@ private fun FriendsDetailPane( onRemove: (Long) -> Unit, onNickName: (String) -> Unit, onShowGames: () -> Unit, + onAlias: () -> Unit, ) { Surface { Box( @@ -310,6 +317,7 @@ private fun FriendsDetailPane( onRemove = onRemove, onNickName = onNickName, onShowGames = onShowGames, + onAlias = onAlias, ) } }, @@ -342,6 +350,7 @@ private fun ProfileDetailsScreen( onRemove: (Long) -> Unit, onNickName: (String) -> Unit, onShowGames: () -> Unit, + onAlias: () -> Unit, ) { val scrollState = rememberScrollState() val windowWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass @@ -453,6 +462,39 @@ private fun ProfileDetailsScreen( ) } + var showPreviousAliasDialog by rememberSaveable { mutableStateOf(false) } + if (showPreviousAliasDialog) { + AlertDialog( + onDismissRequest = { + showPreviousAliasDialog = false + }, + icon = { Icon(imageVector = Icons.Default.History, contentDescription = null) }, + title = { Text(text = "Past Aliases") }, + text = { + LazyColumn { + items(state.profileFriendAlias) { alias -> + Text(text = alias) + Spacer(modifier = Modifier.height(4.dp)) + } + + if (state.profileFriendAlias.isEmpty()) { + item { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = "No past aliases found") + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { showPreviousAliasDialog = false }, + content = { Text(text = "Close") }, + ) + }, + ) + } + Scaffold( topBar = { // Show Top App Bar when in Compact or Medium screen space. @@ -574,8 +616,8 @@ private fun ProfileDetailsScreen( icon = Icons.Outlined.History, text = "View Aliases", onClick = { - // TODO - Toast.makeText(context, "Aliases TODO", Toast.LENGTH_SHORT).show() + onAlias() + showPreviousAliasDialog = true }, ) Spacer(modifier = Modifier.width(16.dp)) @@ -752,6 +794,7 @@ private fun Preview_FriendsScreenContent( onBlock = { }, onRemove = { }, onNickName = { }, + onAlias = { }, ) } } From e46622a92a688cfa67bd281d287799480ba9722c Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 31 Jan 2025 10:34:57 -0600 Subject: [PATCH 42/49] Nit: Back handler should go back to start screen set by preference. --- .../main/java/com/OxGames/Pluvia/ui/data/HomeState.kt | 1 - .../main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt index fd4bd8fa..16c62894 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/data/HomeState.kt @@ -4,7 +4,6 @@ import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.ui.enums.HomeDestination data class HomeState( - // Allow user to set screen for launch. But we also respect pressing back to go to the Library. val currentDestination: HomeDestination = PrefManager.startScreen, val confirmExit: Boolean = false, ) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt index 9de20025..cce564f9 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/HomeScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.toSize import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.OxGames.Pluvia.Constants +import com.OxGames.Pluvia.PrefManager import com.OxGames.Pluvia.ui.component.dialog.MessageDialog import com.OxGames.Pluvia.ui.enums.HomeDestination import com.OxGames.Pluvia.ui.model.HomeViewModel @@ -42,12 +43,12 @@ fun HomeScreen( ) { val homeState by viewModel.homeState.collectAsStateWithLifecycle() - // When in Downloads or Friends, pressing back brings us back to Library - BackHandler(enabled = homeState.currentDestination != HomeDestination.Library) { - viewModel.onDestination(HomeDestination.Library) + // When in Downloads or Friends, pressing back brings us back to default screen from preference (Default: Library) + BackHandler(enabled = homeState.currentDestination != PrefManager.startScreen) { + viewModel.onDestination(PrefManager.startScreen) } // Pressing back again; while logged in, confirm we want to close the app. - BackHandler(enabled = homeState.currentDestination == HomeDestination.Library) { + BackHandler(enabled = homeState.currentDestination == PrefManager.startScreen) { viewModel.onConfirmExit(true) } From 56e5665b17f01afe313d1f67deaaa659e8edac54 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 31 Jan 2025 10:48:18 -0600 Subject: [PATCH 43/49] Remove profile action button from top bar in chat. Added option to view profile through friends list if you click on their icon. Long press still the same. --- .../Pluvia/ui/screen/chat/ChatScreen.kt | 25 ++++++++----------- .../Pluvia/ui/screen/friends/FriendItem.kt | 6 ++++- .../java/com/OxGames/Pluvia/ui/util/Images.kt | 3 ++- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt index 541c03e8..f1f7eb0e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatScreen.kt @@ -29,11 +29,9 @@ import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown -import androidx.compose.material.icons.filled.Person import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -141,11 +139,10 @@ private fun ChatScreenContent( ChatTopBar( steamFriend = state.friend, onBack = onBack, - onProfile = { - // TODO - val msg = "View profile not implemented!\nTry long pressing a friend in the friends list?" - scope.launch { snackbarHost.showSnackbar(msg) } - }, + // onProfile = { + // val msg = "View profile not implemented!\nTry long pressing a friend in the friends list?" + // scope.launch { snackbarHost.showSnackbar(msg) } + // }, ) }, ) { paddingValues -> @@ -276,7 +273,7 @@ private fun NoChatHistoryBox() { private fun ChatTopBar( steamFriend: SteamFriend, onBack: () -> Unit, - onProfile: () -> Unit, + // onProfile: () -> Unit, ) { CenterAlignedTopAppBar( title = { @@ -340,12 +337,12 @@ private fun ChatTopBar( navigationIcon = { BackButton(onClick = onBack) }, - actions = { - IconButton( - onClick = onProfile, - content = { Icon(imageVector = Icons.Default.Person, contentDescription = null) }, - ) - }, + // actions = { + // IconButton( + // onClick = onProfile, + // content = { Icon(imageVector = Icons.Default.Person, contentDescription = null) }, + // ) + // }, ) } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt index 0812c09e..4bc32719 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendItem.kt @@ -2,6 +2,7 @@ package com.OxGames.Pluvia.ui.screen.friends import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.text.InlineTextContent @@ -102,7 +103,10 @@ fun FriendItem( } }, leadingContent = { - ListItemImage { friend.avatarHash.getAvatarURL() } + ListItemImage( + modifier = Modifier.clickable { onLongClick(friend) }, + image = { friend.avatarHash.getAvatarURL() }, + ) }, ) } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt b/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt index 9c90ea7c..b4d213f6 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/util/Images.kt @@ -21,12 +21,13 @@ import com.skydoves.landscapist.coil.CoilImage @Composable internal fun ListItemImage( + modifier: Modifier = Modifier, contentDescription: String? = null, size: Dp = 40.dp, image: () -> Any?, ) { CoilImage( - modifier = Modifier + modifier = modifier .size(size) .clip(CircleShape), imageModel = image, From 06c9f1f83981c29b8e1579546b3cf66fbd9caa15 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 31 Jan 2025 14:43:36 -0600 Subject: [PATCH 44/49] Fix some nits. --- .../com/OxGames/Pluvia/data/OwnedGames.kt | 1 - .../Pluvia/service/SteamUnifiedFriends.kt | 1 - .../OxGames/Pluvia/ui/component/BBCodeText.kt | 4 ++- .../ui/component/dialog/GamesListDialog.kt | 10 +++--- .../Pluvia/ui/model/FriendsViewModel.kt | 35 +++++++++++++------ .../Pluvia/ui/screen/friends/FriendsScreen.kt | 2 ++ .../com/OxGames/Pluvia/utils/SteamUtils.kt | 20 +++++------ 7 files changed, 44 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt b/app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt index 0be37f30..d46e40c9 100644 --- a/app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt +++ b/app/src/main/java/com/OxGames/Pluvia/data/OwnedGames.kt @@ -7,5 +7,4 @@ data class OwnedGames( val playtimeForever: Int = 0, val imgIconUrl: String = "", val sortAs: String? = null, - val rtimeLastPlayed: Int = 0, ) diff --git a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt index ba706719..675b26a9 100644 --- a/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/com/OxGames/Pluvia/service/SteamUnifiedFriends.kt @@ -303,7 +303,6 @@ class SteamUnifiedFriends( playtimeForever = game.playtimeForever, imgIconUrl = game.imgIconUrl, sortAs = game.sortAs, - rtimeLastPlayed = game.rtimeLastPlayed, ) } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt index 6fe5f396..40a1dd10 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt @@ -53,7 +53,9 @@ import com.skydoves.landscapist.coil.CoilImage * See: https://steamcommunity.com/comment/ForumTopic/formattinghelp */ -// TODO web rich previews? +// TODO: +// Slash commands +// Rich Previews with http links // private val noParsePattern = "\\[noparse]([^\\[]+)\\[/noparse]".toRegex() private val colonPattern = "\u02D0([^\u02D0]+)\u02D0".toRegex() diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt index f803310e..4f0c6549 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/GamesListDialog.kt @@ -104,9 +104,12 @@ fun GamesListDialog( supportingContent = { Column { CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { - Text(text = "Playtime 2 weeks: ${SteamUtils.formatPlayTime(item.playtimeTwoWeeks)} hrs") - Text(text = "Total playtime: ${SteamUtils.formatPlayTime(item.playtimeForever)} hrs") - Text(text = "Last played: ${SteamUtils.fromSteamTime(item.rtimeLastPlayed)}") + if (item.playtimeTwoWeeks > 10) { + val twoWeeks = SteamUtils.formatPlayTime(item.playtimeTwoWeeks) + Text(text = "Playtime last 2 weeks: $twoWeeks hrs") + } + val total = SteamUtils.formatPlayTime(item.playtimeForever) + Text(text = "Total Playtime: $total hrs") } } }, @@ -142,7 +145,6 @@ private fun Preview_GamesListDialog() { playtimeForever = 19154 * it, imgIconUrl = "", sortAs = "Game Name Alt: $it", - rtimeLastPlayed = 1731210123 * it, ) }, onDismissRequest = { }, diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt index 5209bd67..daf2346e 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/FriendsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.OxGames.Pluvia.PluviaApp import com.OxGames.Pluvia.PrefManager +import com.OxGames.Pluvia.data.OwnedGames import com.OxGames.Pluvia.db.dao.SteamFriendDao import com.OxGames.Pluvia.events.SteamEvent import com.OxGames.Pluvia.service.SteamService @@ -62,18 +63,30 @@ class FriendsViewModel @Inject constructor( } viewModelScope.launch { - val resp = SteamService.getProfileInfo(SteamID(friendID)) - _friendsState.update { it.copy(profileFriendInfo = resp) } - } - - viewModelScope.launch { - val resp = SteamService.getOwnedGames(friendID) - _friendsState.update { it.copy(profileFriendGames = resp) } - } + launch { + val resp = SteamService.getProfileInfo(SteamID(friendID)) + _friendsState.update { it.copy(profileFriendInfo = resp) } + } + launch { + val resp = SteamService.getOwnedGames(friendID).sortedWith( + compareBy { (it.sortAs ?: it.name).lowercase() } + .thenByDescending { it.playtimeTwoWeeks }, + ) + + resp.forEach { + Timber.d(it.toString()) + } - selectedFriendJob = viewModelScope.launch { - steamFriendDao.findFriendFlow(friendID).collect { friend -> - _friendsState.update { it.copy(profileFriend = friend) } + _friendsState.update { it.copy(profileFriendGames = resp) } + } + selectedFriendJob = launch { + steamFriendDao.findFriendFlow(friendID).collect { friend -> + if (friend == null) { + Timber.w("Collecting friend was null") + return@collect + } + _friendsState.update { it.copy(profileFriend = friend) } + } } } } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 6e3b2be6..85339fb2 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -122,6 +122,8 @@ import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfo import `in`.dragonbra.javasteam.types.SteamID import java.util.Date +// TODO pressing back wont make the selected profile go to the initial details screen. + @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun FriendsScreen( diff --git a/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt b/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt index 75f9f8f7..20fdaf33 100644 --- a/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt +++ b/app/src/main/java/com/OxGames/Pluvia/utils/SteamUtils.kt @@ -6,10 +6,8 @@ import android.provider.Settings import com.OxGames.Pluvia.service.SteamService import `in`.dragonbra.javasteam.util.HardwareUtils import java.io.FileOutputStream -import java.math.RoundingMode import java.nio.file.Files import java.nio.file.Paths -import java.text.DecimalFormat import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone @@ -25,12 +23,6 @@ object SteamUtils { } } - private val df by lazy { - DecimalFormat("#.#").apply { - roundingMode = RoundingMode.HALF_UP - } - } - /** * Converts steam time to actual time * @return a string in the 'MMM d - h:mm a' format. @@ -40,10 +32,16 @@ object SteamUtils { /** * Converts steam time from the playtime of a friend into an approximate double representing hours. - * @return A double representing how many hours were played, ie: 1.5 hrs + * @return A string representing how many hours were played, ie: 1.5 hrs */ - // TODO validate accuracy - fun formatPlayTime(rtime: Int): String = df.format(rtime / 60.0) + fun formatPlayTime(time: Int): String { + val hours = time / 60.0 + return if (hours % 1 == 0.0) { + hours.toInt().toString() + } else { + String.format(Locale.getDefault(), "%.1f", time / 60.0) + } + } /** * Strips non-ASCII characters from String From 0e2a75543ec07ced3a02c45125a573ddc6ef998d Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 31 Jan 2025 14:51:37 -0600 Subject: [PATCH 45/49] Keep games list opened on rotation. --- .../java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 85339fb2..6b45c14c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -172,7 +172,7 @@ private fun FriendsScreenContent( val listState = rememberLazyListState() // Hoisted high to preserve state val snackbarHost = remember { SnackbarHostState() } - var showGamesDialog by remember { mutableStateOf(false) } + var showGamesDialog by rememberSaveable { mutableStateOf(false) } GamesListDialog( visible = showGamesDialog, From 05539c1de9fde3d1370fe0a066ce406008444b2f Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 31 Jan 2025 15:04:41 -0600 Subject: [PATCH 46/49] Remove trying to save image vector as it'll crash on rotation. --- .../Pluvia/ui/component/dialog/state/MessageDialogState.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt index 2a31e8e6..9de16eb2 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt @@ -9,7 +9,7 @@ data class MessageDialogState( val type: DialogType = DialogType.NONE, val confirmBtnText: String = "Confirm", val dismissBtnText: String = "Dismiss", - val icon: ImageVector? = null, + val icon: ImageVector? = null, // TODO unable to be saved. val title: String? = null, val message: String? = null, ) { @@ -21,8 +21,6 @@ data class MessageDialogState( "type" to state.type, "confirmBtnText" to state.confirmBtnText, "dismissBtnText" to state.dismissBtnText, - // this will probably break once a dialog is used with an icon - "icon" to state.icon, "title" to state.title, "message" to state.message, ) @@ -33,7 +31,6 @@ data class MessageDialogState( type = savedMap["type"] as DialogType, confirmBtnText = savedMap["confirmBtnText"] as String, dismissBtnText = savedMap["dismissBtnText"] as String, - icon = savedMap["icon"] as ImageVector?, title = savedMap["title"] as String?, message = savedMap["message"] as String?, ) From 69256d5c90656bf6a8f0acf5c06f9769fa93210c Mon Sep 17 00:00:00 2001 From: Oxters Date: Fri, 31 Jan 2025 16:20:05 -0500 Subject: [PATCH 47/49] Changed how the icon is chosen for a MessageDialog --- .../main/java/com/OxGames/Pluvia/ui/PluviaMain.kt | 2 +- .../component/dialog/state/MessageDialogState.kt | 2 -- .../java/com/OxGames/Pluvia/ui/enums/DialogType.kt | 14 ++++++++++---- .../Pluvia/ui/screen/friends/FriendsScreen.kt | 8 +------- .../ui/screen/library/HomeLibraryAppScreen.kt | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt index be50ea5c..c9f28c05 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/PluviaMain.kt @@ -376,7 +376,7 @@ fun PluviaMain( confirmBtnText = msgDialogState.confirmBtnText, onDismissClick = onDismissClick, dismissBtnText = msgDialogState.dismissBtnText, - icon = msgDialogState.icon, + icon = msgDialogState.type.icon, title = msgDialogState.title, message = msgDialogState.message, ) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt index 9de16eb2..e0268470 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/dialog/state/MessageDialogState.kt @@ -1,7 +1,6 @@ package com.OxGames.Pluvia.ui.component.dialog.state import androidx.compose.runtime.saveable.mapSaver -import androidx.compose.ui.graphics.vector.ImageVector import com.OxGames.Pluvia.ui.enums.DialogType data class MessageDialogState( @@ -9,7 +8,6 @@ data class MessageDialogState( val type: DialogType = DialogType.NONE, val confirmBtnText: String = "Confirm", val dismissBtnText: String = "Dismiss", - val icon: ImageVector? = null, // TODO unable to be saved. val title: String? = null, val message: String? = null, ) { diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt b/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt index ac45ca81..04813ecc 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/enums/DialogType.kt @@ -1,6 +1,12 @@ package com.OxGames.Pluvia.ui.enums -enum class DialogType { +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.ui.graphics.vector.ImageVector + +enum class DialogType(val icon: ImageVector? = null) { CRASH, SUPPORT, SYNC_CONFLICT, @@ -20,8 +26,8 @@ enum class DialogType { NONE, - FRIEND_BLOCK, - FRIEND_REMOVE, - FRIEND_FAVORITE, + FRIEND_BLOCK(Icons.Default.Block), + FRIEND_REMOVE(Icons.Default.PersonRemove), + FRIEND_FAVORITE(Icons.Default.Favorite), FRIEND_UN_FAVORITE, } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt index 6b45c14c..0f67a098 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendsScreen.kt @@ -32,11 +32,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Chat -import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Favorite @@ -417,7 +414,7 @@ private fun ProfileDetailsScreen( confirmBtnText = msgDialogState.confirmBtnText, onDismissClick = onDismissClick, dismissBtnText = msgDialogState.dismissBtnText, - icon = msgDialogState.icon, + icon = msgDialogState.type.icon, title = msgDialogState.title, message = msgDialogState.message, ) @@ -640,7 +637,6 @@ private fun ProfileDetailsScreen( type = DialogType.FRIEND_BLOCK, confirmBtnText = "Block", dismissBtnText = "Cancel", - icon = Icons.Default.Block, title = "Block Friend", message = "Are you sure you want to block ${state.profileFriend.nameOrNickname}?\n" + "This will block them on all steam clients.", @@ -657,7 +653,6 @@ private fun ProfileDetailsScreen( type = DialogType.FRIEND_REMOVE, confirmBtnText = "Remove", dismissBtnText = "Cancel", - icon = Icons.Default.PersonRemove, title = "Remove Friend", message = "Are you sure you want to remove ${state.profileFriend.nameOrNickname}?\n" + "This will remove them on all steam clients.", @@ -682,7 +677,6 @@ private fun ProfileDetailsScreen( type = DialogType.FRIEND_FAVORITE, confirmBtnText = "Favorite", dismissBtnText = "Cancel", - icon = Icons.Default.Favorite, title = "Favorite Friend", message = "Are you sure you want to favorite ${state.profileFriend.nameOrNickname}?\n" + "This will favorite them on all steam clients.", diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryAppScreen.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryAppScreen.kt index 1bf25ae2..60197cc9 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryAppScreen.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/library/HomeLibraryAppScreen.kt @@ -228,7 +228,7 @@ fun AppScreen( confirmBtnText = msgDialogState.confirmBtnText, onDismissClick = onDismissClick, dismissBtnText = msgDialogState.dismissBtnText, - icon = msgDialogState.icon, + icon = msgDialogState.type.icon, title = msgDialogState.title, message = msgDialogState.message, ) From ad078318d4d703642fb82a67633821f2cd9f629f Mon Sep 17 00:00:00 2001 From: Oxters Date: Fri, 31 Jan 2025 20:37:58 -0500 Subject: [PATCH 48/49] Organized BBCode patterns in a more extendable manner. Replaced raw strings with their route counterparts in the when statement in the MainViewModel. Let the chat input grow up to 3 lines max. Made the friend group headers be clickable throughout. --- .../OxGames/Pluvia/ui/component/BBCodeText.kt | 140 +++++++++--------- .../OxGames/Pluvia/ui/model/MainViewModel.kt | 10 +- .../Pluvia/ui/screen/chat/ChatInput.kt | 9 +- .../ui/screen/friends/FriendStickyHeader.kt | 3 + 4 files changed, 84 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt index 40a1dd10..32f59f24 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt @@ -57,29 +57,41 @@ import com.skydoves.landscapist.coil.CoilImage // Slash commands // Rich Previews with http links -// private val noParsePattern = "\\[noparse]([^\\[]+)\\[/noparse]".toRegex() -private val colonPattern = "\u02D0([^\u02D0]+)\u02D0".toRegex() -private val emoticonPattern = "\\[emoticon]([^\\[]+)\\[/emoticon]".toRegex() -private val h1Pattern = "\\[h1]([^\\[]+)\\[/h1]".toRegex() -private val h2Pattern = "\\[h2]([^\\[]+)\\[/h2]".toRegex() -private val h3Pattern = "\\[h3]([^\\[]+)\\[/h3]".toRegex() -private val boldPattern = "\\[b]([^\\[]+)\\[/b]".toRegex() -private val italicPattern = "\\[i]([^\\[]+)\\[/i]".toRegex() -private val underlinePattern = "\\[u]([^\\[]+)\\[/u]".toRegex() -private val strikePattern = "\\[strike]([^\\[]+)\\[/strike]".toRegex() -private val spoilerPattern = "\\[spoiler]([^\\[]+)\\[/spoiler]".toRegex() -private val urlPattern = "\\[url=([^]]+)]([^\\[]+)\\[/url]".toRegex() -private val plainUrlPattern = "(https?://\\S+)".toRegex() -private val hrPattern = "\\[hr]([^\\[]*?)\\[/hr]".toRegex() -private val codePattern = "\\[code]([^\\[]*?)\\[/code]".toRegex() -private val quotePattern = "\\[quote=([^]]+)]([^\\[]*?)\\[/quote]".toRegex() -private val stickerPattern = "\\[sticker type=\"(.*?)\".*?]\\[/sticker]".toRegex() +enum class BBCode(val pattern: String, val groupCount: Int = 1) { + COLON("\u02D0([^\u02D0]+)\u02D0"), + EMOTICON("\\[emoticon]([^\\[]+)\\[/emoticon]"), + H1("\\[h1]([^\\[]+)\\[/h1]"), + H2("\\[h2]([^\\[]+)\\[/h2]"), + H3("\\[h3]([^\\[]+)\\[/h3]"), + BOLD("\\[b]([^\\[]+)\\[/b]"), + ITALIC("\\[i]([^\\[]+)\\[/i]"), + UNDERLINE("\\[u]([^\\[]+)\\[/u]"), + STRIKE_THROUGH("\\[strike]([^\\[]+)\\[/strike]"), + SPOILER("\\[spoiler]([^\\[]+)\\[/spoiler]"), + URL("\\[url=([^]]+)]([^\\[]+)\\[/url]", 2), + PLAIN_URL("(https?://\\S+)"), + HORIZONTAL_RULE("\\[hr]([^\\[]*?)\\[/hr]"), + CODE("\\[code]([^\\[]*?)\\[/code]"), + QUOTE("\\[quote=([^]]+)]([^\\[]*?)\\[/quote]", 2), + STICKER("\\[sticker type=\"(.*?)\".*?]\\[/sticker]"), + ; -private val bbCodePattern = ( - "$colonPattern|$emoticonPattern|$h1Pattern|$h2Pattern|$h3Pattern|$boldPattern|$underlinePattern|" + - "$italicPattern|$strikePattern|$spoilerPattern|$urlPattern|$plainUrlPattern|$hrPattern|$codePattern|" + - "$quotePattern|$stickerPattern" - ).toRegex() + fun groupIndex(): Int = + ordinal + 1 + entries.foldIndexed( + 0, + ) { index, accum, current -> + if (index < ordinal) + accum + current.groupCount - 1 + else accum + } + + companion object { + fun pattern(): Regex = entries + .map { it.pattern } + .joinToString("|") + .toRegex() + } +} @Composable fun BBCodeText( @@ -91,7 +103,7 @@ fun BBCodeText( val revealedSpoilers = remember { mutableStateMapOf() } val layoutResult = remember { mutableStateOf(null) } - val matches = bbCodePattern.findAll(text).toList() + val matches = BBCode.pattern().findAll(text).toList() val annotatedString = buildAnnotatedString { var currentIndex = 0 @@ -103,81 +115,75 @@ fun BBCodeText( } when { - match.groups[1] != null || match.groups[2] != null -> { + match.groups[BBCode.COLON.groupIndex()] != null + || match.groups[BBCode.EMOTICON.groupIndex()] != null + -> { val emoticonName = match.groupValues .getOrNull(1) ?.takeUnless { it.isEmpty() } - ?: match.groupValues.getOrNull(2) + ?: match.groupValues.getOrNull(BBCode.EMOTICON.groupIndex()) emoticonName?.let { emoticon -> appendInlineContent(emoticon, "[emoji]") } } - // H1 - match.groups[3] != null -> { + match.groups[BBCode.H1.groupIndex()] != null -> { withStyle( style = SpanStyle( fontSize = style.fontSize * 1.5f, fontWeight = FontWeight.Bold, baselineShift = BaselineShift(0.2f), ), - block = { append(match.groupValues[3]) }, + block = { append(match.groupValues[BBCode.H1.groupIndex()]) }, ) } - // H2 - match.groups[4] != null -> { + match.groups[BBCode.H2.groupIndex()] != null -> { withStyle( style = SpanStyle( fontSize = style.fontSize * 1.25f, fontWeight = FontWeight.Bold, baselineShift = BaselineShift(0.2f), ), - block = { append(match.groupValues[4]) }, + block = { append(match.groupValues[BBCode.H2.groupIndex()]) }, ) } - // H3 - match.groups[5] != null -> { + match.groups[BBCode.H3.groupIndex()] != null -> { withStyle( style = SpanStyle( fontSize = style.fontSize * 1.10f, fontWeight = FontWeight.Bold, baselineShift = BaselineShift(0.2f), ), - block = { append(match.groupValues[5]) }, + block = { append(match.groupValues[BBCode.H3.groupIndex()]) }, ) } - // Bold - match.groups[6] != null -> { + match.groups[BBCode.BOLD.groupIndex()] != null -> { withStyle( style = SpanStyle(fontWeight = FontWeight.Bold, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[6]) }, + block = { append(match.groupValues[BBCode.BOLD.groupIndex()]) }, ) } - // Underline - match.groups[7] != null -> { + match.groups[BBCode.UNDERLINE.groupIndex()] != null -> { withStyle( style = SpanStyle(textDecoration = TextDecoration.Underline, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[7]) }, + block = { append(match.groupValues[BBCode.UNDERLINE.groupIndex()]) }, ) } - // Italic - match.groups[8] != null -> { + match.groups[BBCode.ITALIC.groupIndex()] != null -> { withStyle( style = SpanStyle(fontStyle = FontStyle.Italic, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[8]) }, + block = { append(match.groupValues[BBCode.ITALIC.groupIndex()]) }, ) } - // Strike-through - match.groups[9] != null -> { + match.groups[BBCode.STRIKE_THROUGH.groupIndex()] != null -> { withStyle( style = SpanStyle(textDecoration = TextDecoration.LineThrough, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[9]) }, + block = { append(match.groupValues[BBCode.STRIKE_THROUGH.groupIndex()]) }, ) } - // Spoiler - match.groups[10] != null -> { - val spoilerText = match.groupValues[10] + match.groups[BBCode.SPOILER.groupIndex()] != null -> { + val spoilerText = match.groupValues[BBCode.SPOILER.groupIndex()] val spoilerId = "spoiler_${match.range.first}" val isRevealed = revealedSpoilers[spoilerId] ?: false @@ -193,10 +199,11 @@ fun BBCodeText( ) pop() } - // BBcode URL - match.groups[11] != null && match.groups[12] != null -> { - val url = match.groupValues[11] - val linkText = match.groupValues[12].trim() + match.groups[BBCode.URL.groupIndex()] != null + && match.groups[BBCode.URL.groupIndex() + 1] != null + -> { + val url = match.groupValues[BBCode.URL.groupIndex()] + val linkText = match.groupValues[BBCode.URL.groupIndex() + 1].trim() pushStringAnnotation("URL", url) withStyle( @@ -209,9 +216,8 @@ fun BBCodeText( ) pop() } - // Plain URL - match.groups[13] != null -> { - val url = match.groupValues[13] + match.groups[BBCode.PLAIN_URL.groupIndex()] != null -> { + val url = match.groupValues[BBCode.PLAIN_URL.groupIndex()] pushStringAnnotation("URL", url) withStyle( style = SpanStyle( @@ -223,22 +229,21 @@ fun BBCodeText( ) pop() } - // Horizontal Rule - match.groups[14] != null -> { + match.groups[BBCode.HORIZONTAL_RULE.groupIndex()] != null -> { withStyle( style = SpanStyle(textDecoration = TextDecoration.LineThrough, baselineShift = BaselineShift(0.2f)), block = { append(" ") }, ) } - // Code - match.groups[15] != null -> { + match.groups[BBCode.CODE.groupIndex()] != null -> { withStyle( style = SpanStyle(fontFamily = FontFamily.Monospace, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[15]) }, + block = { append(match.groupValues[BBCode.CODE.groupIndex()]) }, ) } - // Quote - match.groups[16] != null && match.groups[17] != null -> { + match.groups[BBCode.QUOTE.groupIndex()] != null + && match.groups[BBCode.QUOTE.groupIndex() + 1] != null + -> { withStyle( style = SpanStyle( background = MaterialTheme.colorScheme.surfaceVariant, @@ -247,15 +252,14 @@ fun BBCodeText( ) { withStyle( style = SpanStyle(fontStyle = FontStyle.Italic, baselineShift = BaselineShift(0.2f)), - block = { append("Originally posted by ${match.groupValues[16]}:\n") }, + block = { append("Originally posted by ${match.groupValues[BBCode.QUOTE.groupIndex()]}:\n") }, ) - append(match.groupValues[17]) + append(match.groupValues[BBCode.QUOTE.groupIndex() + 1]) } } - // Sticker - match.groups[18] != null -> { - val stickerType = match.groupValues[18] + match.groups[BBCode.STICKER.groupIndex()] != null -> { + val stickerType = match.groupValues[BBCode.STICKER.groupIndex()] val stickerId = "sticker_$stickerType" appendInlineContent(stickerId, "[sticker]") } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt b/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt index dbdcf7d0..50fca358 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/model/MainViewModel.kt @@ -149,11 +149,11 @@ class MainViewModel @Inject constructor( fun setCurrentScreen(currentScreen: String?) { val screen = when (currentScreen) { - "login" -> PluviaScreen.LoginUser - "home" -> PluviaScreen.Home - "xserver" -> PluviaScreen.XServer - "settings" -> PluviaScreen.Settings - "chat/{id}" -> PluviaScreen.Chat + PluviaScreen.LoginUser.route -> PluviaScreen.LoginUser + PluviaScreen.Home.route -> PluviaScreen.Home + PluviaScreen.XServer.route -> PluviaScreen.XServer + PluviaScreen.Settings.route -> PluviaScreen.Settings + PluviaScreen.Chat.route -> PluviaScreen.Chat else -> PluviaScreen.LoginUser } diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt index cfe36eb1..bd296cf7 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -226,13 +227,11 @@ private fun UserInputText( onEmoticonClick: () -> Unit, ) { Box( - Modifier - .height(56.dp) - .fillMaxSize(), + Modifier.fillMaxWidth(), ) { UserInputTextField( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .semantics { keyboardShownProperty = keyboardShown }, @@ -277,7 +276,7 @@ private fun UserInputTextField( keyboardActions = KeyboardActions { if (textFieldValue.text.isNotBlank()) onMessageSent() }, - maxLines = 1, + maxLines = 3, textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), placeholder = { Text(text = "Send a message") diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt index 391e8242..dbad246c 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/friends/FriendStickyHeader.kt @@ -1,6 +1,7 @@ package com.OxGames.Pluvia.ui.screen.friends import android.content.res.Configuration +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.KeyboardArrowDown @@ -10,6 +11,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.OxGames.Pluvia.data.SteamFriend import com.OxGames.Pluvia.ui.theme.PluviaTheme @@ -18,6 +20,7 @@ import `in`.dragonbra.javasteam.enums.EPersonaState @Composable fun StickyHeaderItem(isCollapsed: Boolean, header: String, count: Int, onHeaderAction: () -> Unit) { ListItem( + modifier = Modifier.clickable(onClick = onHeaderAction), headlineContent = { Text(text = "$header ($count)") }, trailingContent = { val button = when (isCollapsed) { From 95865eba52fdbe00481675cf97eb83459ed8de76 Mon Sep 17 00:00:00 2001 From: Oxters Date: Fri, 31 Jan 2025 20:42:46 -0500 Subject: [PATCH 49/49] Ran lint format --- .../OxGames/Pluvia/ui/component/BBCodeText.kt | 18 ++++++++++-------- .../OxGames/Pluvia/ui/screen/chat/ChatInput.kt | 1 - 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt index 32f59f24..22950ab8 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/component/BBCodeText.kt @@ -80,9 +80,11 @@ enum class BBCode(val pattern: String, val groupCount: Int = 1) { ordinal + 1 + entries.foldIndexed( 0, ) { index, accum, current -> - if (index < ordinal) + if (index < ordinal) { accum + current.groupCount - 1 - else accum + } else { + accum + } } companion object { @@ -115,8 +117,8 @@ fun BBCodeText( } when { - match.groups[BBCode.COLON.groupIndex()] != null - || match.groups[BBCode.EMOTICON.groupIndex()] != null + match.groups[BBCode.COLON.groupIndex()] != null || + match.groups[BBCode.EMOTICON.groupIndex()] != null -> { val emoticonName = match.groupValues .getOrNull(1) @@ -199,8 +201,8 @@ fun BBCodeText( ) pop() } - match.groups[BBCode.URL.groupIndex()] != null - && match.groups[BBCode.URL.groupIndex() + 1] != null + match.groups[BBCode.URL.groupIndex()] != null && + match.groups[BBCode.URL.groupIndex() + 1] != null -> { val url = match.groupValues[BBCode.URL.groupIndex()] val linkText = match.groupValues[BBCode.URL.groupIndex() + 1].trim() @@ -241,8 +243,8 @@ fun BBCodeText( block = { append(match.groupValues[BBCode.CODE.groupIndex()]) }, ) } - match.groups[BBCode.QUOTE.groupIndex()] != null - && match.groups[BBCode.QUOTE.groupIndex() + 1] != null + match.groups[BBCode.QUOTE.groupIndex()] != null && + match.groups[BBCode.QUOTE.groupIndex() + 1] != null -> { withStyle( style = SpanStyle( diff --git a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt index bd296cf7..52f45f89 100644 --- a/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt +++ b/app/src/main/java/com/OxGames/Pluvia/ui/screen/chat/ChatInput.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items