Simple chat & small fixes

This commit is contained in:
2021-09-12 23:35:23 +03:00
parent f098a9ff12
commit 400ff118b5
51 changed files with 1610 additions and 203 deletions
+1 -1
View File
@@ -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")
@@ -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," +
@@ -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("&amp", "&")
}
fun parseForwards(baseForwards: List<BaseVkMessage>?): List<VkMessage>? {
if (baseForwards.isNullOrEmpty()) return null
@@ -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)
}
@@ -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
@@ -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()
@@ -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()
@@ -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<VkMessage>? = null
@IgnoredOnParcel
@Ignore
var attachments: List<VkAttachment>? = 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"),
@@ -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()
@@ -0,0 +1,5 @@
package com.meloda.fast.api.model.attachments
data class VkGroupCall(
val initiatorId: Int
) : VkAttachment()
@@ -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
@@ -40,6 +40,7 @@ data class BaseVkMessage(
peerId = peerId,
fromId = fromId,
date = date,
randomId = randomId,
action = action?.type,
actionMemberId = action?.memberId,
actionText = action?.text,
@@ -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"
}
@@ -8,10 +8,10 @@ import retrofit2.http.*
interface AuthRepo {
@GET(VKUrls.Auth.directAuth)
@GET(VKUrls.Auth.DirectAuth)
suspend fun auth(@QueryMap param: Map<String, String?>): Answer<ResponseAuthDirect>
@GET(VKUrls.Auth.sendSms)
@GET(VKUrls.Auth.SendSms)
suspend fun sendSms(@Query("sid") validationSid: String): Answer<ResponseSendSms>
}
@@ -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<String, String>): Answer<ApiResponse<ConversationsGetResponse>>
}
@@ -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<String, String>): Answer<ApiResponse<MessagesGetHistoryResponse>>
@FormUrlEncoded
@POST(VKUrls.Messages.Send)
suspend fun send(@FieldMap params: Map<String, String>): Answer<ApiResponse<Int>>
}
@@ -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<String, String>?
): Answer<ApiResponse<List<BaseVkUser>>>
@@ -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 {
@@ -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() }
}
}
@@ -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<BaseVkMessage> = listOf(),
val conversations: List<BaseVkConversation>?,
val profiles: List<BaseVkUser>?,
val groups: List<BaseVkGroup>?
) : Parcelable
@@ -24,18 +24,24 @@ abstract class BaseAdapter<Item, VH : BaseHolder>(
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<Item, VH : BaseHolder>(
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
}
@@ -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() {
@@ -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(
@@ -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,
@@ -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
}
}
@@ -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<VkConversation>,
val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup>
@@ -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<VkMessage>,
val conversation: VkConversation,
val profiles: HashMap<Int, VkUser> = hashMapOf(),
val groups: HashMap<Int, VkGroup> = hashMapOf()
) : BaseAdapter<VkMessage, MessagesHistoryAdapter.Holder>(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<VkMessage>() {
override fun areItemsTheSame(
oldItem: VkMessage,
newItem: VkMessage
) = false
override fun areContentsTheSame(
oldItem: VkMessage,
newItem: VkMessage
) = false
}
}
}
@@ -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<MessagesHistoryViewModel>(R.layout.fragment_messages_history) {
override val viewModel: MessagesHistoryViewModel by viewModels()
private val binding: FragmentMessagesHistoryBinding by viewBinding()
private val action = MutableLiveData<Action>()
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<VkMessage>) {
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)
}
}
@@ -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<Int, VkUser>()
response.profiles?.let { baseProfiles ->
baseProfiles.forEach { baseProfile ->
baseProfile.asVkUser().let { profile -> profiles[profile.id] = profile }
}
}
val groups = hashMapOf<Int, VkGroup>()
response.groups?.let { baseGroups ->
baseGroups.forEach { baseGroup ->
baseGroup.asVkGroup().let { group -> groups[group.id] = group }
}
}
val messages = hashMapOf<Int, VkMessage>()
response.items.forEach { baseMessage ->
baseMessage.asVkMessage().let { message -> messages[message.id] = message }
}
val conversations = hashMapOf<Int, VkConversation>()
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<Int, VkConversation>,
val messages: List<VkMessage>,
val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup>
) : VKEvent()
@@ -71,8 +71,6 @@ object AndroidUtils {
}
return color
}
}
@@ -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)
}
}
}
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/white" />
<corners
android:bottomLeftRadius="5dp"
android:bottomRightRadius="40dp"
android:topLeftRadius="30dp"
android:topRightRadius="40dp" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/white" />
<corners
android:bottomLeftRadius="40dp"
android:bottomRightRadius="5dp"
android:topLeftRadius="40dp"
android:topRightRadius="30dp" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:bottomLeftRadius="30dp"
android:bottomRightRadius="5dp"
android:topLeftRadius="30dp"
android:topRightRadius="30dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:centerColor="#ffffff"
android:centerY="0.625"
android:endColor="@android:color/transparent"
android:startColor="#ffffff"
android:type="linear" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="-90"
android:centerColor="#ffffff"
android:centerY="0.625"
android:endColor="@android:color/transparent"
android:startColor="#ffffff"
android:type="linear" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.91,11c-0.49,0 -0.9,0.36 -0.98,0.85C16.52,14.2 14.47,16 12,16s-4.52,-1.8 -4.93,-4.15c-0.08,-0.49 -0.49,-0.85 -0.98,-0.85 -0.61,0 -1.09,0.54 -1,1.14 0.49,3 2.89,5.35 5.91,5.78L11,20c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-2.08c3.02,-0.43 5.42,-2.78 5.91,-5.78 0.1,-0.6 -0.39,-1.14 -1,-1.14z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M3.4,20.4l17.45,-7.48c0.81,-0.35 0.81,-1.49 0,-1.84L3.4,3.6c-0.66,-0.29 -1.39,0.2 -1.39,0.91L2,9.12c0,0.5 0.37,0.93 0.87,0.99L17,12 2.87,13.88c-0.5,0.07 -0.87,0.5 -0.87,1l0.01,4.61c0,0.71 0.73,1.2 1.39,0.91z"/>
</vector>
@@ -1,106 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
xmlns:tools="http://schemas.android.com/tools">
<com.google.android.material.appbar.AppBarLayout
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:elevation="0dp">
android:layout_height="match_parent">
<com.google.android.material.appbar.CollapsingToolbarLayout
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="0dp"
app:collapsedTitleTextAppearance="@style/CollapsingToolbarCollapsedTitle"
app:expandedTitleTextAppearance="@style/CollapsingToolbarTitle"
app:layout_scrollFlags="scroll|enterAlways|snap"
app:title="Messages">
app:elevation="0dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:background="@color/n1_50"
android:elevation="0dp"
app:layout_collapseMode="pin"
app:menu="@menu/fragment_conversations" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/expandedImage"
android:layout_width="match_parent"
android:layout_height="140dp"
android:elevation="0dp" />
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="30dp"
android:orientation="horizontal">
android:elevation="0dp"
app:collapsedTitleTextAppearance="@style/CollapsingToolbarCollapsedTitle"
app:expandedTitleTextAppearance="@style/CollapsingToolbarTitle"
app:layout_scrollFlags="scroll|enterAlways|snap"
app:title="Messages">
<androidx.appcompat.widget.AppCompatImageButton
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginEnd="16dp"
android:background="?selectableItemBackgroundBorderless"
android:src="@drawable/ic_search"
android:tint="@color/a1_500" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:background="@color/n1_50"
android:elevation="0dp"
app:layout_collapseMode="pin"
app:menu="@menu/fragment_conversations" />
<com.meloda.fast.widget.CircleImageView
android:id="@+id/avatar"
android:layout_width="30dp"
android:layout_height="30dp"
tools:src="@tools:sample/avatars" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/expandedImage"
android:layout_width="match_parent"
android:layout_height="140dp"
android:elevation="0dp" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="30dp"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatImageButton
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginEnd="16dp"
android:background="?selectableItemBackgroundBorderless"
android:src="@drawable/ic_search"
android:tint="@color/a1_500" />
<com.meloda.fast.widget.CircleImageView
android:id="@+id/avatar"
android:layout_width="30dp"
android:layout_height="30dp"
tools:src="@tools:sample/avatars" />
</androidx.appcompat.widget.LinearLayoutCompat>
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_conversation" />
app:layout_behavior="@string/appbar_scrolling_view_behavior">
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_conversation" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/createChat"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="end|bottom"
android:layout_margin="16dp"
android:src="@drawable/ic_baseline_create_24"
app:backgroundTint="@color/a2_200"
app:elevation="3dp"
app:fabSize="normal"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
app:pressedTranslationZ="1dp"
app:shapeAppearanceOverlay="@style/RoundedView.56"
app:tint="@color/a2_700"
tools:ignore="ContentDescription" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/createChat"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="end|bottom"
android:layout_margin="16dp"
android:src="@drawable/ic_baseline_create_24"
app:backgroundTint="@color/a2_200"
app:elevation="3dp"
app:fabSize="normal"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
app:pressedTranslationZ="1dp"
app:shapeAppearanceOverlay="@style/RoundedView.56"
app:tint="@color/a2_700"
tools:ignore="ContentDescription" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
@@ -0,0 +1,247 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="86dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="100"
tools:listitem="@layout/item_message_out" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<FrameLayout
android:id="@+id/toolbarContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:background="@drawable/ic_messages_history_toolbar_gradient_background"
android:backgroundTint="@color/n1_50"
android:minHeight="140dp">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="30dp"
android:paddingTop="18dp"
android:paddingBottom="24dp">
<FrameLayout
android:layout_width="56dp"
android:layout_height="56dp">
<com.meloda.fast.widget.CircleImageView
android:id="@+id/avatar"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:src="@tools:sample/avatars"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/avatarPlaceholder"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<com.meloda.fast.widget.CircleImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@color/n1_50" />
<com.meloda.fast.widget.CircleImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/ic_account_circle_cut"
app:tint="@color/n2_500" />
</FrameLayout>
<FrameLayout
android:id="@+id/online"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="end|bottom"
android:visibility="gone"
tools:visibility="visible">
<com.meloda.fast.widget.CircleImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center"
android:src="@color/n1_50" />
<com.meloda.fast.widget.CircleImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:src="@drawable/ic_online_pc"
app:tint="@color/a3_200" />
</FrameLayout>
<FrameLayout
android:id="@+id/service"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="end|top"
android:visibility="gone"
tools:visibility="visible">
<com.meloda.fast.widget.CircleImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:background="@drawable/ic_back"
android:backgroundTint="@color/n2_500"
android:elevation="0.5dp" />
<com.meloda.fast.widget.CircleImageView
android:id="@+id/phantomIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:elevation="1dp"
android:src="@drawable/ic_phantom"
android:visibility="gone"
app:tint="@color/n2_10"
tools:visibility="visible" />
<com.meloda.fast.widget.CircleImageView
android:id="@+id/callIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:elevation="1dp"
android:src="@drawable/ic_attachment_group_call"
android:visibility="gone"
app:tint="@color/n2_0" />
</FrameLayout>
</FrameLayout>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textColor="@color/n1_900"
android:textSize="24sp"
tools:text="@tools:sample/full_names" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.7"
android:textColor="@color/n1_900"
tools:text="Online" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
<com.google.android.material.chip.Chip
android:id="@+id/timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:layout_marginBottom="30dp"
android:elevation="2dp"
android:enabled="false"
android:paddingHorizontal="16dp"
android:paddingVertical="4dp"
android:textColor="@color/n1_900"
android:visibility="gone"
app:chipBackgroundColor="@color/n1_100"
app:textEndPadding="12dp"
app:textStartPadding="12dp"
tools:text="today"
tools:visibility="visible" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_gravity="bottom"
android:background="@drawable/ic_message_panel_gradient"
android:backgroundTint="@color/n1_50" />
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/messagePanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_margin="12dp"
android:background="@drawable/ic_message_panel_background"
android:backgroundTint="@color/a1_0"
android:clickable="true"
android:elevation="3dp"
android:focusable="true"
android:minHeight="60dp"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:layout_marginHorizontal="20dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:hint="@string/message_input_hint" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/attach"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:layout_marginEnd="18dp"
android:background="?selectableItemBackgroundBorderless"
android:src="@drawable/ic_baseline_attach_file_24"
android:tint="@color/a1_500" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:layout_marginEnd="20dp"
android:background="?selectableItemBackgroundBorderless"
android:src="@drawable/ic_round_mic_24"
android:tint="@color/a1_500" />
</androidx.appcompat.widget.LinearLayoutCompat>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
+32 -31
View File
@@ -6,9 +6,8 @@
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:orientation="horizontal"
android:paddingVertical="4dp">
android:layout_marginVertical="4dp"
android:orientation="horizontal">
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/container"
@@ -30,13 +29,13 @@
android:id="@+id/avatar"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:src="@tools:sample/avatars"
tools:visibility="gone" />
tools:src="@tools:sample/avatars" />
<FrameLayout
android:id="@+id/avatarPlaceholder"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:visibility="gone">
<com.meloda.fast.widget.CircleImageView
android:layout_width="match_parent"
@@ -51,6 +50,28 @@
</FrameLayout>
<FrameLayout
android:id="@+id/online"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="end|bottom"
android:visibility="gone"
tools:visibility="visible">
<com.meloda.fast.widget.CircleImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center"
android:src="@color/n1_50" />
<com.meloda.fast.widget.CircleImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:src="@drawable/ic_online_pc"
app:tint="@color/a3_200" />
</FrameLayout>
<FrameLayout
android:id="@+id/service"
@@ -90,31 +111,6 @@
app:tint="@color/n2_0" />
</FrameLayout>
<FrameLayout
android:id="@+id/online"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="end|bottom"
android:visibility="gone"
tools:visibility="visible">
<com.meloda.fast.widget.CircleImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center"
android:src="@color/n1_50" />
<com.meloda.fast.widget.CircleImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:src="@drawable/ic_online_pc"
app:tint="@color/a3_200" />
</FrameLayout>
</FrameLayout>
<androidx.appcompat.widget.LinearLayoutCompat
@@ -221,6 +217,11 @@
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?selectableItemBackground" />
</FrameLayout>
</layout>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="2.5dp">
<com.meloda.fast.widget.CircleImageView
android:id="@+id/avatar"
android:layout_width="35dp"
android:layout_height="35dp"
android:layout_gravity="bottom"
android:layout_marginEnd="12dp"
tools:src="@tools:sample/avatars" />
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="8dp"
android:fontFamily="@font/google_sans_regular"
android:textColor="@color/a3_700"
tools:text="@tools:sample/full_names" />
<com.meloda.fast.widget.BoundedFrameLayout
android:id="@+id/bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/ic_message_in_background"
android:backgroundTint="@color/n2_100"
tools:ignore="UselessParent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:padding="15dp"
android:textColor="@color/n1_800"
tools:text="This" />
</com.meloda.fast.widget.BoundedFrameLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
</layout>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end|bottom"
android:paddingHorizontal="12dp"
android:paddingVertical="2.5dp">
<com.meloda.fast.widget.CircleImageView
android:id="@+id/unread"
android:layout_width="13dp"
android:layout_height="13dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="20dp"
android:src="@color/a3_200" />
<FrameLayout
android:id="@+id/bubbleStroke"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/ic_message_out_background"
android:backgroundTint="@color/n2_100"
android:padding="1.5dp"
tools:ignore="UselessParent">
<com.meloda.fast.widget.BoundedFrameLayout
android:id="@+id/bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/ic_message_out_background"
android:backgroundTint="@color/n1_10">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|start"
android:padding="15dp"
android:textColor="@color/n1_800"
tools:text="This is test" />
</com.meloda.fast.widget.BoundedFrameLayout>
</FrameLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
</layout>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Service" />
</androidx.appcompat.widget.LinearLayoutCompat>
</layout>
+14 -2
View File
@@ -7,8 +7,20 @@
<fragment
android:id="@+id/conversationsFragment"
android:name="com.meloda.fast.screens.messages.ConversationsFragment"
android:name="com.meloda.fast.screens.conversations.ConversationsFragment"
android:label="ConversationsFragment"
tools:layout="@layout/fragment_conversations" />
tools:layout="@layout/fragment_conversations">
<action
android:id="@+id/toMessagesHistory"
app:destination="@+id/messagesHistoryFragment" />
</fragment>
<fragment
android:id="@+id/messagesHistoryFragment"
android:name="com.meloda.fast.screens.messages.MessagesHistoryFragment"
android:label="MessagesHistoryFragment"
tools:layout="@layout/fragment_messages_history" />
</navigation>
+4
View File
@@ -9,13 +9,17 @@
<color name="a2_700">@android:color/system_accent2_700</color>
<color name="a3_200">@android:color/system_accent3_200</color>
<color name="a3_700">@android:color/system_accent3_700</color>
<color name="n1_10">@android:color/system_neutral1_10</color>
<color name="n1_50">@android:color/system_neutral1_50</color>
<color name="n1_100">@android:color/system_neutral1_100</color>
<color name="n1_800">@android:color/system_neutral1_800</color>
<color name="n1_900">@android:color/system_neutral1_900</color>
<color name="n2_0">@android:color/system_neutral2_0</color>
<color name="n2_10">@android:color/system_neutral2_10</color>
<color name="n2_100">@android:color/system_neutral2_100</color>
<color name="n2_500">@android:color/system_neutral2_500</color>
</resources>
+4 -2
View File
@@ -9,15 +9,17 @@
<color name="a2_700">#414757</color>
<color name="a3_200">#DEBAE5</color>
<color name="a3_700">#583C61</color>
<color name="n1_10">#FBF9FC</color>
<color name="n1_50">#F1F1F1</color>
<color name="n1_100">#E2E1E5</color>
<color name="n1_800">#303033</color>
<color name="n1_900">#1B1B1D</color>
<color name="n2_0">#FFFFFF</color>
<color name="n2_10">#FDFBFE</color>
<color name="n2_100">#E0E2EB</color>
<color name="n2_500">#74767D</color>
<!-- Dark Theme Colors -->
</resources>
+11
View File
@@ -34,4 +34,15 @@
<string name="messages_self_destructed">Messages self-destructed</string>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
<string name="message_input_hint">Start typing here...</string>
</resources>