From 400ff118b5c43d3469f89855cd15a87b5b4c03f9 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sun, 12 Sep 2021 23:35:23 +0300 Subject: [PATCH] Simple chat & small fixes --- app/build.gradle.kts | 2 +- .../kotlin/com/meloda/fast/api/VKConstants.kt | 1 + .../kotlin/com/meloda/fast/api/VkUtils.kt | 7 +- .../fast/api/datasource/MessagesDataSource.kt | 6 + .../meloda/fast/api/model/VkConversation.kt | 8 +- .../com/meloda/fast/api/model/VkGroup.kt | 5 +- .../com/meloda/fast/api/model/VkGroupCall.kt | 7 - .../com/meloda/fast/api/model/VkMessage.kt | 44 ++- .../com/meloda/fast/api/model/VkUser.kt | 5 +- .../fast/api/model/attachments/VkGroupCall.kt | 5 + .../fast/api/model/base/BaseVkConversation.kt | 3 +- .../fast/api/model/base/BaseVkMessage.kt | 1 + .../com/meloda/fast/api/network/VKUrls.kt | 13 +- .../meloda/fast/api/network/repo/AuthRepo.kt | 4 +- .../api/network/repo/ConversationsRepo.kt | 2 +- .../fast/api/network/repo/MessagesRepo.kt | 17 ++ .../meloda/fast/api/network/repo/UsersRepo.kt | 2 +- .../network/request/ConversationsRequest.kt | 2 - .../api/network/request/MessagesRequest.kt | 58 ++++ .../api/network/response/MessagesResponse.kt | 17 ++ .../meloda/fast/base/adapter/BaseAdapter.kt | 39 +-- .../com/meloda/fast/database/AppDatabase.kt | 2 +- .../{api/VKModules.kt => di/NetworkModule.kt} | 8 +- .../ConversationsAdapter.kt | 16 +- .../ConversationsFragment.kt | 37 ++- .../ConversationsViewModel.kt | 17 +- .../messages/MessagesHistoryAdapter.kt | 197 ++++++++++++ .../messages/MessagesHistoryFragment.kt | 285 ++++++++++++++++++ .../messages/MessagesHistoryViewModel.kt | 120 ++++++++ .../com/meloda/fast/util/AndroidUtils.kt | 2 - .../kotlin/com/meloda/fast/util/TimeUtils.kt | 57 ++++ .../meloda/fast/widget/BoundedFrameLayout.kt | 64 ++++ .../meloda/fast/widget/BoundedLinearLayout.kt | 2 +- .../meloda/fast/widget/ScrollingTextView.kt | 28 ++ .../res/drawable/ic_message_in_background.xml | 13 + .../drawable/ic_message_out_background.xml | 13 + .../drawable/ic_message_panel_background.xml | 13 + .../drawable/ic_message_panel_gradient.xml | 13 + ...es_history_toolbar_gradient_background.xml | 13 + app/src/main/res/drawable/ic_round_mic_24.xml | 9 + .../main/res/drawable/ic_round_send_24.xml | 11 + .../res/layout/fragment_conversations.xml | 169 ++++++----- .../res/layout/fragment_messages_history.xml | 247 +++++++++++++++ app/src/main/res/layout/item_conversation.xml | 63 ++-- app/src/main/res/layout/item_message_in.xml | 59 ++++ app/src/main/res/layout/item_message_out.xml | 52 ++++ .../main/res/layout/item_message_service.xml | 18 ++ app/src/main/res/navigation/messages.xml | 16 +- app/src/main/res/values-v31/colors.xml | 4 + app/src/main/res/values/colors.xml | 6 +- app/src/main/res/values/strings.xml | 11 + 51 files changed, 1610 insertions(+), 203 deletions(-) delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/model/VkGroupCall.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/request/MessagesRequest.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/response/MessagesResponse.kt rename app/src/main/kotlin/com/meloda/fast/{api/VKModules.kt => di/NetworkModule.kt} (94%) rename app/src/main/kotlin/com/meloda/fast/screens/{messages => conversations}/ConversationsAdapter.kt (91%) rename app/src/main/kotlin/com/meloda/fast/screens/{messages => conversations}/ConversationsFragment.kt (78%) rename app/src/main/kotlin/com/meloda/fast/screens/{messages => conversations}/ConversationsViewModel.kt (87%) create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt create mode 100644 app/src/main/res/drawable/ic_message_in_background.xml create mode 100644 app/src/main/res/drawable/ic_message_out_background.xml create mode 100644 app/src/main/res/drawable/ic_message_panel_background.xml create mode 100644 app/src/main/res/drawable/ic_message_panel_gradient.xml create mode 100644 app/src/main/res/drawable/ic_messages_history_toolbar_gradient_background.xml create mode 100644 app/src/main/res/drawable/ic_round_mic_24.xml create mode 100644 app/src/main/res/drawable/ic_round_send_24.xml create mode 100644 app/src/main/res/layout/fragment_messages_history.xml create mode 100644 app/src/main/res/layout/item_message_in.xml create mode 100644 app/src/main/res/layout/item_message_out.xml create mode 100644 app/src/main/res/layout/item_message_service.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa9a3a33..98bb520a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,7 +113,7 @@ dependencies { kapt("com.google.dagger:hilt-android-compiler:2.38.1") implementation("androidx.hilt:hilt-navigation-fragment:1.0.0") - implementation("com.github.yogacp:android-viewbinding:1.0.2") + implementation("com.github.yogacp:android-viewbinding:1.0.3") implementation("io.coil-kt:coil:1.3.2") diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt index 5c4843c9..c462639f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt @@ -12,6 +12,7 @@ object VKConstants { const val VK_APP_ID = "2274003" const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH" + const val FAST_GROUP_ID = -119516304 object Auth { const val SCOPE = "notify," + diff --git a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt index 165ff157..bfa61f8c 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt @@ -5,7 +5,6 @@ import android.graphics.drawable.Drawable import androidx.core.content.ContextCompat import com.meloda.fast.R import com.meloda.fast.api.model.VkGroup -import com.meloda.fast.api.model.VkGroupCall import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.* @@ -25,6 +24,12 @@ object VkUtils { return throwable.error == VKErrors.NEED_CAPTCHA } + fun prepareMessageText(text: String): String { + return text + .replace("\n", " ") + .replace("&", "&") + } + fun parseForwards(baseForwards: List?): List? { if (baseForwards.isNullOrEmpty()) return null diff --git a/app/src/main/kotlin/com/meloda/fast/api/datasource/MessagesDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/datasource/MessagesDataSource.kt index 2c1fe461..6e2fc477 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/datasource/MessagesDataSource.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/datasource/MessagesDataSource.kt @@ -1,6 +1,8 @@ package com.meloda.fast.api.datasource import com.meloda.fast.api.network.repo.MessagesRepo +import com.meloda.fast.api.network.request.MessagesGetHistoryRequest +import com.meloda.fast.api.network.request.MessagesSendRequest import com.meloda.fast.database.dao.MessagesDao import javax.inject.Inject @@ -9,4 +11,8 @@ class MessagesDataSource @Inject constructor( private val dao: MessagesDao ) { + suspend fun getHistory(params: MessagesGetHistoryRequest) = repo.getHistory(params.map) + + suspend fun send(params: MessagesSendRequest) = repo.send(params.map) + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt index e983e03d..0ef30970 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt @@ -1,10 +1,13 @@ package com.meloda.fast.api.model +import android.os.Parcelable import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize @Entity(tableName = "conversations") +@Parcelize data class VkConversation( @PrimaryKey(autoGenerate = false) val id: Int, @@ -18,8 +21,9 @@ data class VkConversation( val outRead: Int, val isMarkedUnread: Boolean, val lastMessageId: Int, - val unreadCount: Int? -) { + val unreadCount: Int?, + val membersCount: Int? +) : Parcelable { @Ignore var lastMessage: VkMessage? = null diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkGroup.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkGroup.kt index 6a7cabbc..df8aaffd 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkGroup.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkGroup.kt @@ -1,16 +1,19 @@ package com.meloda.fast.api.model +import android.os.Parcelable import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize @Entity(tableName = "groups") +@Parcelize data class VkGroup( @PrimaryKey(autoGenerate = false) val id: Int, val name: String, val screenName: String, val photo200: String? -) { +): Parcelable { override fun toString() = name.trim() diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkGroupCall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkGroupCall.kt deleted file mode 100644 index f7dad5f2..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkGroupCall.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.meloda.fast.api.model - -import com.meloda.fast.api.model.attachments.VkAttachment - -data class VkGroupCall( - val initiatorId: Int -) : VkAttachment() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt index 00083bae..58f45998 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt @@ -1,41 +1,69 @@ package com.meloda.fast.api.model +import android.os.Parcelable import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import com.meloda.fast.api.model.attachments.VkAttachment +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize @Entity(tableName = "messages") +@Parcelize data class VkMessage( @PrimaryKey(autoGenerate = false) val id: Int, - val text: String?, + val text: String? = null, val isOut: Boolean, val peerId: Int, val fromId: Int, val date: Int, - val action: String?, - val actionMemberId: Int?, - val actionText: String?, - val actionConversationMessageId: Int?, - val actionMessage: String?, - val geoType: String? -) { + val randomId: Int, + val action: String? = null, + val actionMemberId: Int? = null, + val actionText: String? = null, + val actionConversationMessageId: Int? = null, + val actionMessage: String? = null, + val geoType: String? = null +) : Parcelable { + + @IgnoredOnParcel @Ignore var forwards: List? = null + @IgnoredOnParcel @Ignore var attachments: List? = null + fun isPeerChat() = peerId > 2_000_000_000 + fun isUser() = fromId > 0 fun isGroup() = fromId < 0 + fun isRead(conversation: VkConversation) = conversation.outRead < id + fun getPreparedAction(): Action? { if (action == null) return null return Action.parse(action) } + fun changeId(id: Int) = VkMessage( + id = id, + text = text, + isOut = isOut, + peerId = peerId, + fromId = fromId, + date = date, + randomId = randomId, + action = action, + actionMemberId = actionMemberId, + actionText = actionText, + actionConversationMessageId = actionConversationMessageId, + actionMessage = actionMessage, + geoType = geoType + ) + enum class Action(val value: String) { CHAT_CREATE("chat_create"), CHAT_PHOTO_UPDATE("chat_photo_update"), diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt index 5e28b35c..c1f5a2e7 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt @@ -1,9 +1,12 @@ package com.meloda.fast.api.model +import android.os.Parcelable import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize @Entity(tableName = "users") +@Parcelize data class VkUser( @PrimaryKey(autoGenerate = false) val id: Int, @@ -11,7 +14,7 @@ data class VkUser( val lastName: String, val online: Boolean, val photo200: String? -) { +) : Parcelable { override fun toString() = "$firstName $lastName".trim() diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt new file mode 100644 index 00000000..f23d0194 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt @@ -0,0 +1,5 @@ +package com.meloda.fast.api.model.attachments + +data class VkGroupCall( + val initiatorId: Int +) : VkAttachment() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt index fa32c387..d69818f8 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt @@ -50,7 +50,8 @@ data class BaseVkConversation( outRead = outRead, isMarkedUnread = isMarkedUnread, lastMessageId = lastMessageId, - unreadCount = unreadCount + unreadCount = unreadCount, + membersCount = chatSettings?.membersCount ).apply { this.lastMessage = lastMessage } @Parcelize diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt index 0c698b82..8a816ad2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt @@ -40,6 +40,7 @@ data class BaseVkMessage( peerId = peerId, fromId = fromId, date = date, + randomId = randomId, action = action?.type, actionMemberId = action?.memberId, actionText = action?.text, diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/VKUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/VKUrls.kt index 4a627038..7395f62f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/VKUrls.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/VKUrls.kt @@ -6,16 +6,21 @@ object VKUrls { const val API = "https://api.vk.com/method" object Auth { - const val directAuth = "$OAUTH/token" - const val sendSms = "$API/auth.validatePhone" + const val DirectAuth = "$OAUTH/token" + const val SendSms = "$API/auth.validatePhone" } object Conversations { - const val get = "$API/messages.getConversations" + const val Get = "$API/messages.getConversations" } object Users { - const val getById = "$API/users.get" + const val GetById = "$API/users.get" + } + + object Messages { + const val GetHistory = "$API/messages.getHistory" + const val Send = "$API/messages.send" } diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/repo/AuthRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/repo/AuthRepo.kt index 93c92fa7..39fde13a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/repo/AuthRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/repo/AuthRepo.kt @@ -8,10 +8,10 @@ import retrofit2.http.* interface AuthRepo { - @GET(VKUrls.Auth.directAuth) + @GET(VKUrls.Auth.DirectAuth) suspend fun auth(@QueryMap param: Map): Answer - @GET(VKUrls.Auth.sendSms) + @GET(VKUrls.Auth.SendSms) suspend fun sendSms(@Query("sid") validationSid: String): Answer } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/repo/ConversationsRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/repo/ConversationsRepo.kt index 882d8ee9..54e988af 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/repo/ConversationsRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/repo/ConversationsRepo.kt @@ -11,7 +11,7 @@ import retrofit2.http.POST interface ConversationsRepo { @FormUrlEncoded - @POST(VKUrls.Conversations.get) + @POST(VKUrls.Conversations.Get) suspend fun getAllChats(@FieldMap params: Map): Answer> } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/repo/MessagesRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/repo/MessagesRepo.kt index e6920550..084796e3 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/repo/MessagesRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/repo/MessagesRepo.kt @@ -1,4 +1,21 @@ package com.meloda.fast.api.network.repo +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.VKUrls +import com.meloda.fast.api.network.response.MessagesGetHistoryResponse +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + interface MessagesRepo { + + @FormUrlEncoded + @POST(VKUrls.Messages.GetHistory) + suspend fun getHistory(@FieldMap params: Map): Answer> + + @FormUrlEncoded + @POST(VKUrls.Messages.Send) + suspend fun send(@FieldMap params: Map): Answer> + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/repo/UsersRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/repo/UsersRepo.kt index 4df465d4..782cb32f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/repo/UsersRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/repo/UsersRepo.kt @@ -11,7 +11,7 @@ import retrofit2.http.POST interface UsersRepo { @FormUrlEncoded - @POST(VKUrls.Users.getById) + @POST(VKUrls.Users.GetById) suspend fun getById( @FieldMap params: Map? ): Answer>> diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/request/ConversationsRequest.kt b/app/src/main/kotlin/com/meloda/fast/api/network/request/ConversationsRequest.kt index f612d7e5..ce6b6cea 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/request/ConversationsRequest.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/request/ConversationsRequest.kt @@ -1,7 +1,6 @@ package com.meloda.fast.api.network.request import android.os.Parcelable -import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize @Parcelize @@ -11,7 +10,6 @@ data class ConversationsGetRequest( val fields: String = "", val filter: String = "all", val extended: Boolean? = true, - @SerializedName("start_message_id") val startMessageId: Int? = null ) : Parcelable { diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/request/MessagesRequest.kt b/app/src/main/kotlin/com/meloda/fast/api/network/request/MessagesRequest.kt new file mode 100644 index 00000000..5e7f0096 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/request/MessagesRequest.kt @@ -0,0 +1,58 @@ +package com.meloda.fast.api.network.request + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MessagesGetHistoryRequest( + val count: Int? = null, + val offset: Int? = null, + val peerId: Int, + val extended: Boolean? = null, + val startMessageId: Int? = null, + val rev: Boolean? = null, + val fields: String? = null, +) : Parcelable { + + val map + get() = mutableMapOf( + "peer_id" to peerId.toString() + ).apply { + count?.let { this["count"] = it.toString() } + offset?.let { this["offset"] = it.toString() } + extended?.let { this["extended"] = (if (it) 1 else 0).toString() } + startMessageId?.let { this["start_message_id"] = it.toString() } + rev?.let { this["rev"] = (if (it) 1 else 0).toString() } + fields?.let { this["fields"] = it } + } + +} + +@Parcelize +data class MessagesSendRequest( + val peerId: Int, + val randomId: Int = 0, + val message: String? = null, + val lat: Int? = null, + val lon: Int? = null, + val replyTo: Int? = null, + val stickerId: Int? = null, + val disableMentions: Boolean? = null, + val dontParseLinks: Boolean? = null +) : Parcelable { + + val map + get() = mutableMapOf( + "peer_id" to peerId.toString(), + "random_id" to randomId.toString() + ).apply { + message?.let { this["message"] = it } + lat?.let { this["lat"] = it.toString() } + lon?.let { this["lon"] = it.toString() } + replyTo?.let { this["reply_to"] = it.toString() } + stickerId?.let { this["sticker_id"] = it.toString() } + disableMentions?.let { this["disable_mentions"] = (if (it) 1 else 0).toString() } + dontParseLinks?.let { this["dont_parse_links"] = (if (it) 1 else 0).toString() } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/response/MessagesResponse.kt b/app/src/main/kotlin/com/meloda/fast/api/network/response/MessagesResponse.kt new file mode 100644 index 00000000..bec4576f --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/response/MessagesResponse.kt @@ -0,0 +1,17 @@ +package com.meloda.fast.api.network.response + +import android.os.Parcelable +import com.meloda.fast.api.model.base.BaseVkConversation +import com.meloda.fast.api.model.base.BaseVkGroup +import com.meloda.fast.api.model.base.BaseVkMessage +import com.meloda.fast.api.model.base.BaseVkUser +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MessagesGetHistoryResponse( + val count: Int, + val items: List = listOf(), + val conversations: List?, + val profiles: List?, + val groups: List? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt index fd5abdb0..e77ba993 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt @@ -24,18 +24,24 @@ abstract class BaseAdapter( protected var inflater: LayoutInflater = LayoutInflater.from(context) - var itemClickListener: OnItemClickListener? = null - var itemLongClickListener: OnItemLongClickListener? = null + var itemClickListener: ((position: Int) -> Unit) = {} + var itemLongClickListener: ((position: Int) -> Boolean) = { false } - open fun destroy() { - itemClickListener = null - itemLongClickListener = null - } + open fun destroy() {} override fun getItem(position: Int): Item { return values[position] } + fun getOrNull(position: Int): Item? { + return if (position >= 0 && position <= values.lastIndex) get(position) else null + } + + fun getOrElse(position: Int, defaultValue: (Int) -> Item): Item { + return if (position >= 0 && position <= values.lastIndex) get(position) + else defaultValue(position) + } + fun add(position: Int, item: Item) { values.add(position, item) cleanValues.add(position, item) @@ -103,26 +109,23 @@ abstract class BaseAdapter( onBindItemViewHolder(holder, position) } + private fun onBindItemViewHolder(holder: VH, position: Int) { + initListeners(holder.itemView, position) + holder.bind(position) + } + protected fun initListeners(itemView: View, position: Int) { if (itemView is AdapterView<*>) return - itemView.setOnClickListener { - itemClickListener?.onItemClick(position) - } - - itemView.setOnLongClickListener { - itemLongClickListener?.onItemLongClick(position) - return@setOnLongClickListener itemClickListener == null - } + itemView.setOnClickListener { itemClickListener.invoke(position) } + itemView.setOnLongClickListener { itemLongClickListener.invoke(position) } } override fun getItemCount(): Int { return values.size } - private fun onBindItemViewHolder(holder: VH, position: Int) { - initListeners(holder.itemView, position) - holder.bind(position) - } + val lastPosition + get() = itemCount - 1 } diff --git a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt b/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt index 467d3c21..0494344a 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt +++ b/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt @@ -18,7 +18,7 @@ import com.meloda.fast.database.dao.UsersDao VkUser::class, VkGroup::class ], - version = 11, + version = 13, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKModules.kt b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt similarity index 94% rename from app/src/main/kotlin/com/meloda/fast/api/VKModules.kt rename to app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt index 1983ef58..cb6a23ee 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VKModules.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.api +package com.meloda.fast.di import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -28,7 +28,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -class VKModules { +object NetworkModule { @Singleton @Provides @@ -77,6 +77,10 @@ class VKModules { fun provideUsersRepo(retrofit: Retrofit): UsersRepo = retrofit.create(UsersRepo::class.java) + @Provides + fun provideMessagesRepo(retrofit: Retrofit): MessagesRepo = + retrofit.create(MessagesRepo::class.java) + @Provides @Singleton fun provideAuthDataSource( diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt similarity index 91% rename from app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsAdapter.kt rename to app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt index 01110a1b..f89a67c5 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.screens.messages +package com.meloda.fast.screens.conversations import android.content.Context import android.text.SpannableString @@ -17,7 +17,7 @@ import com.meloda.fast.api.model.VkUser import com.meloda.fast.base.adapter.BaseAdapter import com.meloda.fast.base.adapter.BindingHolder import com.meloda.fast.databinding.ItemConversationBinding -import java.text.SimpleDateFormat +import com.meloda.fast.util.TimeUtils class ConversationsAdapter constructor( context: Context, @@ -76,9 +76,9 @@ class ConversationsAdapter constructor( } else null val avatar = when { - chatUser != null && !chatUser.photo200.isNullOrBlank() -> chatUser.photo200 - chatGroup != null && !chatGroup.photo200.isNullOrBlank() -> chatGroup.photo200 - !conversation.photo200.isNullOrBlank() -> conversation.photo200 + conversation.isUser() && chatUser != null && !chatUser.photo200.isNullOrBlank() -> chatUser.photo200 + conversation.isGroup() && chatGroup != null && !chatGroup.photo200.isNullOrBlank() -> chatGroup.photo200 + conversation.isChat() && !conversation.photo200.isNullOrBlank() -> conversation.photo200 else -> null } @@ -127,11 +127,11 @@ class ConversationsAdapter constructor( message = message ) else null - val messageText = if (actionMessage != null || + val messageText = (if (actionMessage != null || forwardsMessage != null || attachmentText != null ) "" - else message.text ?: "[no_message]" + else message.text ?: "[no_message]").run { VkUtils.prepareMessageText(this) } val coloredMessage = actionMessage ?: attachmentText ?: forwardsMessage ?: "" @@ -165,7 +165,7 @@ class ConversationsAdapter constructor( binding.title.text = getItem(position).title ?: chatUser?.toString() ?: chatGroup?.name ?: "..." - binding.date.text = SimpleDateFormat("HH:mm").format(message.date * 1000) + binding.date.text = TimeUtils.getLocalizedTime(context, message.date * 1000L) binding.container.background = if (conversation.isUnread()) ContextCompat.getDrawable( context, diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt similarity index 78% rename from app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsFragment.kt rename to app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt index be09a244..006f37db 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt @@ -1,10 +1,12 @@ -package com.meloda.fast.screens.messages +package com.meloda.fast.screens.conversations import android.os.Bundle import android.view.View import android.viewbinding.library.fragment.viewBinding +import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController import coil.load import com.google.android.material.snackbar.Snackbar import com.meloda.fast.R @@ -17,7 +19,6 @@ import com.meloda.fast.base.viewmodel.VKEvent import com.meloda.fast.databinding.FragmentConversationsBinding import com.meloda.fast.util.AndroidUtils import dagger.hilt.android.AndroidEntryPoint -import kotlin.math.roundToInt @AndroidEntryPoint class ConversationsFragment : @@ -39,7 +40,10 @@ class ConversationsFragment : prepareViews() - adapter = ConversationsAdapter(requireContext(), mutableListOf()) + adapter = ConversationsAdapter(requireContext(), mutableListOf()).also { + it.itemClickListener = this::onItemClick + it.itemLongClickListener = this::onItemLongClick + } binding.recyclerView.adapter = adapter viewModel.loadConversations() @@ -86,9 +90,7 @@ class ConversationsFragment : private fun prepareRefreshLayout() { with(binding.refreshLayout) { setProgressViewOffset( - true, - AndroidUtils.px(40).roundToInt(), - AndroidUtils.px(96).roundToInt() + true, progressViewStartOffset, progressViewEndOffset ) setProgressBackgroundColorSchemeColor( AndroidUtils.getThemeAttrColor( @@ -107,10 +109,7 @@ class ConversationsFragment : } private fun refreshConversations(event: ConversationsLoaded) { -// adapter.profiles.clear() adapter.profiles += event.profiles - -// adapter.groups.clear() adapter.groups += event.groups fillRecyclerView(event.conversations) @@ -122,4 +121,24 @@ class ConversationsFragment : adapter.notifyItemRangeChanged(0, adapter.itemCount) } + private fun onItemClick(position: Int) { + val conversation = adapter[position] + val user = if (conversation.isUser()) adapter.profiles[conversation.id] else null + val group = if (conversation.isGroup()) adapter.groups[conversation.id] else null + + findNavController().navigate( + R.id.toMessagesHistory, + bundleOf( + "conversation" to adapter[position], + "user" to user, + "group" to group + ) + ) + } + + private fun onItemLongClick(position: Int): Boolean { + binding.createChat.performClick() + return true + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt similarity index 87% rename from app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsViewModel.kt rename to app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt index 08b8e150..66b74857 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/ConversationsViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.screens.messages +package com.meloda.fast.screens.conversations import androidx.lifecycle.viewModelScope import com.meloda.fast.api.UserConfig @@ -31,7 +31,7 @@ class ConversationsViewModel @Inject constructor( dataSource.getAllChats( ConversationsGetRequest( count = 30, -// offset = 37, +// offset = 177, extended = true, fields = "${VKConstants.USER_FIELDS},${VKConstants.GROUP_FIELDS}" ) @@ -49,9 +49,6 @@ class ConversationsViewModel @Inject constructor( baseGroup.asVkGroup().let { group -> groups[group.id] = group } } -// val profiles = response.profiles?.map { profile -> profile.asVkUser() } ?: listOf() -// val groups = response.groups?.map { group -> group.asVkGroup() } ?: listOf() - sendEvent( ConversationsLoaded( count = response.count, @@ -71,12 +68,8 @@ class ConversationsViewModel @Inject constructor( val er = it throw it }, - onStart = { - sendEvent(StartProgressEvent) - }, - onEnd = { - sendEvent(StopProgressEvent) - }) + onStart = { sendEvent(StartProgressEvent) }, + onEnd = { sendEvent(StopProgressEvent) }) } fun loadProfileUser() = viewModelScope.launch { @@ -96,7 +89,7 @@ class ConversationsViewModel @Inject constructor( data class ConversationsLoaded( val count: Int, - val unreadCount: Int, + val unreadCount: Int?, val conversations: List, val profiles: HashMap, val groups: HashMap diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt new file mode 100644 index 00000000..44342538 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt @@ -0,0 +1,197 @@ +package com.meloda.fast.screens.messages + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import coil.load +import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.base.adapter.BaseAdapter +import com.meloda.fast.base.adapter.BaseHolder +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.databinding.ItemMessageInBinding +import com.meloda.fast.databinding.ItemMessageOutBinding +import com.meloda.fast.databinding.ItemMessageServiceBinding +import com.meloda.fast.util.AndroidUtils +import kotlin.math.roundToInt + +class MessagesHistoryAdapter constructor( + context: Context, + values: MutableList, + val conversation: VkConversation, + val profiles: HashMap = hashMapOf(), + val groups: HashMap = hashMapOf() +) : BaseAdapter(context, values, COMPARATOR) { + + override fun getItemViewType(position: Int): Int { + var viewType: Int = when { + isPositionHeader(position) -> HEADER + isPositionFooter(position) -> FOOTER + else -> -1 + } + + if (viewType == -1) { + getItem(position).let { + if (it.action != null) viewType = SERVICE + if (it.isOut) viewType = OUTGOING + if (!it.isOut) viewType = INCOMING + } + } + + return viewType + } + + private fun isPositionHeader(position: Int) = position == 0 + private fun isPositionFooter(position: Int) = position >= actualSize + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return when (viewType) { + HEADER -> Header(createEmptyView(60)) + FOOTER -> Footer(createEmptyView(36)) + SERVICE -> ServiceMessage(ItemMessageServiceBinding.inflate(inflater, parent, false)) + OUTGOING -> OutgoingMessage(ItemMessageOutBinding.inflate(inflater, parent, false)) + INCOMING -> IncomingMessage(ItemMessageInBinding.inflate(inflater, parent, false)) + else -> Holder() + } + } + + private fun createEmptyView(size: Int) = View(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + AndroidUtils.px(size).roundToInt() + ) + + isEnabled = false + isClickable = false + isFocusable = false + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + if (holder is Header || holder is Footer) return + + initListeners(holder.itemView, position) + holder.bind(position) + } + + open inner class Holder(v: View = View(context)) : BaseHolder(v) + + inner class Header(v: View) : Holder(v) + + inner class Footer(v: View) : Holder(v) + + inner class ServiceMessage( + private val binding: ItemMessageServiceBinding + ) : Holder(binding.root) { + + override fun bind(position: Int) { + + } + } + + inner class OutgoingMessage( + private val binding: ItemMessageOutBinding + ) : Holder(binding.root) { + + init { + binding.bubble.maxWidth = (AppGlobal.screenWidth * 0.75).roundToInt() + } + + override fun bind(position: Int) { + val message = getItem(position) + + binding.text.text = message.text ?: "[no_message]" + + binding.unread.isVisible = message.isRead(conversation) + } + + } + + inner class IncomingMessage( + private val binding: ItemMessageInBinding + ) : Holder(binding.root) { + + init { + binding.bubble.maxWidth = (AppGlobal.screenWidth * 0.7).roundToInt() + } + + override fun bind(position: Int) { + val message = getItem(position) + + val prevMessage = getOrNull(position - 1) + val nextMessage = getOrNull(position + 1) + + binding.title.isVisible = + if (prevMessage == null || prevMessage.fromId != message.fromId) message.isPeerChat() + else message.date - prevMessage.date >= 60 + + binding.avatar.visibility = + if (nextMessage == null || nextMessage.fromId != message.fromId) if (message.isPeerChat()) View.VISIBLE else View.GONE + else if (nextMessage.date - message.date >= 60) View.VISIBLE + else View.INVISIBLE + + val messageUser: VkUser? = if (message.isUser()) { + profiles[message.fromId] + } else null + + val messageGroup: VkGroup? = if (message.isGroup()) { + groups[message.fromId] + } else null + + val avatar = when { + message.isUser() && messageUser != null && !messageUser.photo200.isNullOrBlank() -> messageUser.photo200 + message.isGroup() && messageGroup != null && !messageGroup.photo200.isNullOrBlank() -> messageGroup.photo200 + else -> null + } + + val title = when { + message.isUser() && messageUser != null -> messageUser.firstName + message.isGroup() && messageGroup != null -> messageGroup.name + else -> null + } + + binding.avatar.load(avatar) { crossfade(100) } + + binding.text.text = message.text ?: "[no_message]" + + binding.title.text = title + binding.title.measure(0, 0) + + if (binding.title.isVisible) { + binding.bubble.minimumWidth = binding.title.measuredWidth + 60 + } else { + binding.bubble.minimumWidth = 0 + } + } + } + + private val actualSize get() = values.size + + override fun getItemCount(): Int { + if (actualSize == 0) return 2 + return super.getItemCount() + 2 + } + + companion object { + private const val INCOMING = 1001 + private const val OUTGOING = 1002 + private const val SERVICE = 1003 + private const val HEADER = 0 + private const val FOOTER = 2 + + private val COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: VkMessage, + newItem: VkMessage + ) = false + + override fun areContentsTheSame( + oldItem: VkMessage, + newItem: VkMessage + ) = false + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt new file mode 100644 index 00000000..5fb96ea0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt @@ -0,0 +1,285 @@ +package com.meloda.fast.screens.messages + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import android.viewbinding.library.fragment.viewBinding +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.viewModels +import androidx.lifecycle.MutableLiveData +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil.load +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.base.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.StartProgressEvent +import com.meloda.fast.base.viewmodel.StopProgressEvent +import com.meloda.fast.base.viewmodel.VKEvent +import com.meloda.fast.databinding.FragmentMessagesHistoryBinding +import com.meloda.fast.extensions.TextViewExtensions.clear +import com.meloda.fast.util.AndroidUtils +import com.meloda.fast.util.TimeUtils +import dagger.hilt.android.AndroidEntryPoint +import java.text.SimpleDateFormat +import java.util.* +import kotlin.concurrent.schedule + +@AndroidEntryPoint +class MessagesHistoryFragment : + BaseViewModelFragment(R.layout.fragment_messages_history) { + + override val viewModel: MessagesHistoryViewModel by viewModels() + private val binding: FragmentMessagesHistoryBinding by viewBinding() + + private val action = MutableLiveData() + + private enum class Action { + RECORD, SEND + } + + private val user: VkUser? by lazy { + requireArguments().getParcelable("user") + } + + private val group: VkGroup? by lazy { + requireArguments().getParcelable("group") + } + + private val conversation: VkConversation by lazy { + requireNotNull(requireArguments().getParcelable("conversation")) + } + + private val adapter: MessagesHistoryAdapter by lazy { + MessagesHistoryAdapter(requireContext(), mutableListOf(), conversation) + } + + private var timestampTimer: Timer? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val title = when { + conversation.isChat() -> conversation.title + conversation.isUser() -> user?.toString() + conversation.isGroup() -> group?.name + else -> null + } + + binding.title.text = title ?: "..." + + val status = when { + conversation.isChat() -> "${conversation.membersCount} members" + conversation.isUser() -> if (user?.online == true) "Online" else "Last seen at [...]" + conversation.isGroup() -> "[Group status]" + else -> null + } + + binding.status.text = status ?: "..." + + val avatar = when { + conversation.isChat() -> conversation.photo200 + conversation.isUser() -> user?.photo200 + conversation.isGroup() -> group?.photo200 + else -> null + } + + binding.avatar.load(avatar) { + crossfade(false) + error(ColorDrawable(Color.RED)) + } + + binding.online.isVisible = user?.online == true + + prepareViews() + + binding.recyclerView.adapter = adapter + + viewModel.loadHistory(conversation.id) + + binding.action.setOnClickListener { performAction() } + + binding.recyclerView.addOnLayoutChangeListener { _, i, i2, i3, bottom, i5, i6, i7, oldBottom -> + if (bottom >= oldBottom) return@addOnLayoutChangeListener + val lastVisiblePosition = + (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + + if (lastVisiblePosition <= adapter.lastPosition - 10) return@addOnLayoutChangeListener + + binding.recyclerView.postDelayed({ + binding.recyclerView.scrollToPosition(adapter.lastPosition) + }, 25) + } + + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val firstPosition = + (recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + + val message = adapter.getOrNull(firstPosition) + message?.let { + binding.timestamp.isVisible = true + + val time = "${ + TimeUtils.getLocalizedDate( + requireContext(), + it.date * 1000L + ) + }, ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(it.date * 1000L)}" + + binding.timestamp.text = time + + if (timestampTimer != null) { + timestampTimer?.cancel() + timestampTimer = null + } + + timestampTimer = Timer() + timestampTimer?.schedule(2500) { + recyclerView.post { binding.timestamp.isVisible = false } + } + } + + super.onScrolled(recyclerView, dx, dy) + } + }) + + binding.message.doAfterTextChanged { + val newValue = if (it.toString().isNotBlank()) Action.SEND + else Action.RECORD + + if (action.value != newValue) action.value = newValue + } + + action.observe(viewLifecycleOwner) { + + binding.action.animate() + .scaleX(1.25f) + .scaleY(1.25f) + .setDuration(100) + .withEndAction { + binding.action.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(100) + .start() + }.start() + + when (it) { + Action.RECORD -> { + binding.action.setImageResource(R.drawable.ic_round_mic_24) + } + Action.SEND -> { + binding.action.setImageResource(R.drawable.ic_round_send_24) + } + else -> return@observe + } + } + } + + private fun performAction() { + if (action.value == Action.RECORD) { + + } else if (action.value == Action.SEND) { + val messageText = binding.message.text.toString().trim() + if (messageText.isBlank()) return + + val date = System.currentTimeMillis() + + var message = VkMessage( + id = -1, + text = messageText, + isOut = true, + peerId = conversation.id, + fromId = UserConfig.userId, + date = (date / 1000).toInt(), + randomId = 0 + ) + + adapter.add(message) + adapter.notifyItemRangeInserted(adapter.lastPosition - 1, 1) + binding.recyclerView.smoothScrollToPosition(adapter.lastPosition) + binding.message.clear() + + viewModel.sendMessage( + peerId = conversation.id, + message = messageText, + randomId = 0 + ) { message = message.changeId(it) } + } + } + + override fun onEvent(event: VKEvent) { + when (event) { + is MessagesLoaded -> refreshMessages(event) + is StartProgressEvent -> onProgressStarted() + is StopProgressEvent -> onProgressStopped() + } + super.onEvent(event) + } + + private fun onProgressStarted() { + binding.progressBar.isVisible = adapter.isEmpty() + binding.refreshLayout.isRefreshing = adapter.isNotEmpty() + } + + private fun onProgressStopped() { + binding.progressBar.isVisible = false + binding.refreshLayout.isRefreshing = false + } + + private fun prepareViews() { + prepareRecyclerView() + prepareRefreshLayout() + } + + private fun prepareRecyclerView() { + binding.recyclerView.itemAnimator = null + } + + private fun prepareRefreshLayout() { + with(binding.refreshLayout) { + setProgressViewOffset( + true, progressViewStartOffset, progressViewEndOffset + ) + setProgressBackgroundColorSchemeColor( + AndroidUtils.getThemeAttrColor( + requireContext(), + R.attr.colorSurface + ) + ) + setColorSchemeColors( + AndroidUtils.getThemeAttrColor( + requireContext(), + R.attr.colorAccent + ) + ) + setOnRefreshListener { viewModel.loadHistory(peerId = conversation.id) } + } + } + + private fun refreshMessages(event: MessagesLoaded) { + adapter.profiles += event.profiles + adapter.groups += event.groups + + fillRecyclerView(event.messages) + } + + private fun fillRecyclerView(values: List) { + val smoothScroll = adapter.isNotEmpty() + + adapter.values.clear() + adapter.values += values.sortedBy { it.date } + adapter.notifyItemRangeChanged(0, adapter.itemCount) + + if (smoothScroll) binding.recyclerView.smoothScrollToPosition(adapter.lastPosition) + else binding.recyclerView.scrollToPosition(adapter.lastPosition) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt new file mode 100644 index 00000000..7b93fe5a --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt @@ -0,0 +1,120 @@ +package com.meloda.fast.screens.messages + +import androidx.lifecycle.viewModelScope +import com.meloda.fast.api.datasource.MessagesDataSource +import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.network.request.MessagesGetHistoryRequest +import com.meloda.fast.api.network.request.MessagesSendRequest +import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.base.viewmodel.StartProgressEvent +import com.meloda.fast.base.viewmodel.StopProgressEvent +import com.meloda.fast.base.viewmodel.VKEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MessagesHistoryViewModel @Inject constructor( + private val dataSource: MessagesDataSource +) : BaseViewModel() { + + fun loadHistory( + peerId: Int + ) = viewModelScope.launch { + makeJob({ + dataSource.getHistory( + MessagesGetHistoryRequest( + count = 90, + peerId = peerId, + extended = true, + fields = "photo_200,sex" + ) + ) + }, + onAnswer = { + val response = it.response ?: return@makeJob + + val profiles = hashMapOf() + response.profiles?.let { baseProfiles -> + baseProfiles.forEach { baseProfile -> + baseProfile.asVkUser().let { profile -> profiles[profile.id] = profile } + } + } + + val groups = hashMapOf() + response.groups?.let { baseGroups -> + baseGroups.forEach { baseGroup -> + baseGroup.asVkGroup().let { group -> groups[group.id] = group } + } + } + + val messages = hashMapOf() + response.items.forEach { baseMessage -> + baseMessage.asVkMessage().let { message -> messages[message.id] = message } + } + + val conversations = hashMapOf() + response.conversations?.let { baseConversations -> + baseConversations.forEach { baseConversation -> + baseConversation.asVkConversation( + messages[baseConversation.lastMessageId] + ).let { conversation -> conversations[conversation.id] = conversation } + } + } + + sendEvent( + MessagesLoaded( + count = response.count, + profiles = profiles, + groups = groups, + conversations = conversations, + messages = messages.values.toList() + ) + ) + }, + onError = { + val throwable = it + throw it + }, + onStart = { sendEvent(StartProgressEvent) }, + onEnd = { sendEvent(StopProgressEvent) }) + } + + fun sendMessage( + peerId: Int, + message: String? = null, + randomId: Int = 0, + setId: ((messageId: Int) -> Unit)? = null + ) = viewModelScope.launch { + makeJob( + { + dataSource.send( + MessagesSendRequest( + peerId = peerId, + randomId = randomId, + message = message + ) + ) + }, + onAnswer = { + val response = it.response ?: return@makeJob + setId?.invoke(response) + }, + onError = { + val throwable = it + val i = 0 + }) + } + +} + +data class MessagesLoaded( + val count: Int, + val conversations: HashMap, + val messages: List, + val profiles: HashMap, + val groups: HashMap +) : VKEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt index 344075c1..d7e36886 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt @@ -71,8 +71,6 @@ object AndroidUtils { } return color - - } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt index df4f059b..f300bbe2 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt @@ -1,5 +1,8 @@ package com.meloda.fast.util +import android.content.Context +import com.meloda.fast.R +import java.text.SimpleDateFormat import java.util.* object TimeUtils { @@ -14,4 +17,58 @@ object TimeUtils { }.timeInMillis } + fun getLocalizedDate(context: Context, date: Long): String { + val now = Calendar.getInstance() + val then = Calendar.getInstance().also { it.timeInMillis = date } + + val pattern = + if (now[Calendar.YEAR] != then[Calendar.YEAR]) { + "dd MMM yyyy" + } else if (now[Calendar.MONTH] != then[Calendar.MONTH]) { + "dd MMMM" + } else if (now[Calendar.DAY_OF_MONTH] != then[Calendar.DAY_OF_MONTH]) { + if (now[Calendar.DAY_OF_MONTH] - then[Calendar.DAY_OF_MONTH] == 1) { + return context.getString(R.string.yesterday) + } else { + "dd MMMM" + } + } else { + return context.getString(R.string.today) + } + + return SimpleDateFormat(pattern, Locale.getDefault()).format(date) + } + + fun getLocalizedTime(context: Context, date: Long): String { + val now = Calendar.getInstance() + val then = Calendar.getInstance().also { it.timeInMillis = date } + + return when { + now[Calendar.YEAR] != then[Calendar.YEAR] -> { + "${now[Calendar.YEAR] - then[Calendar.YEAR]}${ + context.getString(R.string.year_short).lowercase() + }" + } + now[Calendar.MONTH] != then[Calendar.MONTH] -> { + "${now[Calendar.MONTH] - then[Calendar.MONTH]}${ + context.getString(R.string.month_short).lowercase() + }" + } + now[Calendar.DAY_OF_MONTH] != then[Calendar.DAY_OF_MONTH] -> { + val change = now[Calendar.DAY_OF_MONTH] - then[Calendar.DAY_OF_MONTH] + if (change >= 7) { + "${change / 7}${context.getString(R.string.week_short).lowercase()}" + } else { + "$change${context.getString(R.string.day_short).lowercase()}" + } + } + else -> { + if (now[Calendar.MINUTE] == then[Calendar.MINUTE]) { + context.getString(R.string.time_now).lowercase() + } else { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt new file mode 100644 index 00000000..8781b954 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt @@ -0,0 +1,64 @@ +package com.meloda.fast.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import com.meloda.fast.R + +class BoundedFrameLayout : FrameLayout { + private var mBoundedWidth: Int + private var mBoundedHeight: Int + + constructor(context: Context) : super(context) { + mBoundedWidth = 0 + mBoundedHeight = 0 + } + + @SuppressLint("CustomViewStyleable") + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + val a = context.obtainStyledAttributes(attrs, R.styleable.BoundedView) + mBoundedWidth = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) + mBoundedHeight = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) + a.recycle() + } + + var maxWidth: Int + get() = mBoundedWidth + set(width) { + if (mBoundedWidth != width) { + mBoundedWidth = width + requestLayout() + } + } + + var maxHeight: Int + get() = mBoundedHeight + set(height) { + if (mBoundedHeight != height) { + mBoundedHeight = height + requestLayout() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Adjust width as necessary + var widthMeasureSpec = widthMeasureSpec + var heightMeasureSpec = heightMeasureSpec + val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) + + if (mBoundedWidth in 1 until measuredWidth) { + val measureMode = MeasureSpec.getMode(widthMeasureSpec) + widthMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedWidth, measureMode) + } + + // Adjust height as necessary + val measuredHeight = MeasureSpec.getSize(heightMeasureSpec) + if (mBoundedHeight in 1 until measuredHeight) { + val measureMode = MeasureSpec.getMode(heightMeasureSpec) + heightMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedHeight, measureMode) + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt index c39291b4..754cc4b5 100644 --- a/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt +++ b/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt @@ -10,7 +10,7 @@ class BoundedLinearLayout : LinearLayout { private var mBoundedWidth: Int private var mBoundedHeight: Int - constructor(context: Context?) : super(context) { + constructor(context: Context) : super(context) { mBoundedWidth = 0 mBoundedHeight = 0 } diff --git a/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt b/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt new file mode 100644 index 00000000..3431a7e1 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt @@ -0,0 +1,28 @@ +package com.meloda.fast.widget + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import com.google.android.material.textview.MaterialTextView + +class ScrollingTextView : MaterialTextView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + if (focused) super.onFocusChanged(focused, direction, previouslyFocusedRect) + } + + override fun onWindowFocusChanged(focused: Boolean) { + if (focused) super.onWindowFocusChanged(true) + } + + override fun isFocused(): Boolean { + return true + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_in_background.xml b/app/src/main/res/drawable/ic_message_in_background.xml new file mode 100644 index 00000000..b092122a --- /dev/null +++ b/app/src/main/res/drawable/ic_message_in_background.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_out_background.xml b/app/src/main/res/drawable/ic_message_out_background.xml new file mode 100644 index 00000000..a8d5ce89 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_out_background.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_panel_background.xml b/app/src/main/res/drawable/ic_message_panel_background.xml new file mode 100644 index 00000000..fdcd1463 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_panel_background.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_panel_gradient.xml b/app/src/main/res/drawable/ic_message_panel_gradient.xml new file mode 100644 index 00000000..0b00bdf8 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_panel_gradient.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_messages_history_toolbar_gradient_background.xml b/app/src/main/res/drawable/ic_messages_history_toolbar_gradient_background.xml new file mode 100644 index 00000000..eafd6329 --- /dev/null +++ b/app/src/main/res/drawable/ic_messages_history_toolbar_gradient_background.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_round_mic_24.xml b/app/src/main/res/drawable/ic_round_mic_24.xml new file mode 100644 index 00000000..57219f6e --- /dev/null +++ b/app/src/main/res/drawable/ic_round_mic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_send_24.xml b/app/src/main/res/drawable/ic_round_send_24.xml new file mode 100644 index 00000000..ae931b57 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_send_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/fragment_conversations.xml b/app/src/main/res/layout/fragment_conversations.xml index 26216d18..b14e8b2c 100644 --- a/app/src/main/res/layout/fragment_conversations.xml +++ b/app/src/main/res/layout/fragment_conversations.xml @@ -1,106 +1,109 @@ - + xmlns:tools="http://schemas.android.com/tools"> - + android:layout_height="match_parent"> - + app:elevation="0dp"> - - - - - + android:elevation="0dp" + app:collapsedTitleTextAppearance="@style/CollapsingToolbarCollapsedTitle" + app:expandedTitleTextAppearance="@style/CollapsingToolbarTitle" + app:layout_scrollFlags="scroll|enterAlways|snap" + app:title="Messages"> - + - + - + + + + + + + - + - + - - - + app:layout_behavior="@string/appbar_scrolling_view_behavior"> - + - + - + - \ No newline at end of file + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_messages_history.xml b/app/src/main/res/layout/fragment_messages_history.xml new file mode 100644 index 00000000..93020318 --- /dev/null +++ b/app/src/main/res/layout/fragment_messages_history.xml @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml index 969fe278..359be593 100644 --- a/app/src/main/res/layout/item_conversation.xml +++ b/app/src/main/res/layout/item_conversation.xml @@ -6,9 +6,8 @@ + android:layout_marginVertical="4dp" + android:orientation="horizontal"> + tools:src="@tools:sample/avatars" /> + android:layout_height="match_parent" + android:visibility="gone"> + + + + + + + - - - - - - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_in.xml b/app/src/main/res/layout/item_message_in.xml new file mode 100644 index 00000000..2a9fd45a --- /dev/null +++ b/app/src/main/res/layout/item_message_in.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_out.xml b/app/src/main/res/layout/item_message_out.xml new file mode 100644 index 00000000..b8fe6201 --- /dev/null +++ b/app/src/main/res/layout/item_message_out.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_service.xml b/app/src/main/res/layout/item_message_service.xml new file mode 100644 index 00000000..3fde4ddf --- /dev/null +++ b/app/src/main/res/layout/item_message_service.xml @@ -0,0 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/messages.xml b/app/src/main/res/navigation/messages.xml index e8b26d74..8db6e5ac 100644 --- a/app/src/main/res/navigation/messages.xml +++ b/app/src/main/res/navigation/messages.xml @@ -7,8 +7,20 @@ + tools:layout="@layout/fragment_conversations"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml index fa98ede9..6c942c29 100644 --- a/app/src/main/res/values-v31/colors.xml +++ b/app/src/main/res/values-v31/colors.xml @@ -9,13 +9,17 @@ @android:color/system_accent2_700 @android:color/system_accent3_200 + @android:color/system_accent3_700 + @android:color/system_neutral1_10 @android:color/system_neutral1_50 @android:color/system_neutral1_100 + @android:color/system_neutral1_800 @android:color/system_neutral1_900 @android:color/system_neutral2_0 @android:color/system_neutral2_10 + @android:color/system_neutral2_100 @android:color/system_neutral2_500 \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 5f37ab99..9ed74021 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -9,15 +9,17 @@ #414757 #DEBAE5 + #583C61 + #FBF9FC #F1F1F1 #E2E1E5 + #303033 #1B1B1D #FFFFFF #FDFBFE + #E0E2EB #74767D - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b84398c2..6d9189e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,4 +34,15 @@ Messages self-destructed + Yesterday + + Today + + Y + M + W + D + Now + Start typing here... +