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") kapt("com.google.dagger:hilt-android-compiler:2.38.1")
implementation("androidx.hilt:hilt-navigation-fragment:1.0.0") 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") implementation("io.coil-kt:coil:1.3.2")
@@ -12,6 +12,7 @@ object VKConstants {
const val VK_APP_ID = "2274003" const val VK_APP_ID = "2274003"
const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH" const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH"
const val FAST_GROUP_ID = -119516304
object Auth { object Auth {
const val SCOPE = "notify," + const val SCOPE = "notify," +
@@ -5,7 +5,6 @@ import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.api.model.VkGroup 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.VkMessage
import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.model.attachments.* import com.meloda.fast.api.model.attachments.*
@@ -25,6 +24,12 @@ object VkUtils {
return throwable.error == VKErrors.NEED_CAPTCHA return throwable.error == VKErrors.NEED_CAPTCHA
} }
fun prepareMessageText(text: String): String {
return text
.replace("\n", " ")
.replace("&amp", "&")
}
fun parseForwards(baseForwards: List<BaseVkMessage>?): List<VkMessage>? { fun parseForwards(baseForwards: List<BaseVkMessage>?): List<VkMessage>? {
if (baseForwards.isNullOrEmpty()) return null if (baseForwards.isNullOrEmpty()) return null
@@ -1,6 +1,8 @@
package com.meloda.fast.api.datasource package com.meloda.fast.api.datasource
import com.meloda.fast.api.network.repo.MessagesRepo 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 com.meloda.fast.database.dao.MessagesDao
import javax.inject.Inject import javax.inject.Inject
@@ -9,4 +11,8 @@ class MessagesDataSource @Inject constructor(
private val dao: MessagesDao 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 package com.meloda.fast.api.model
import android.os.Parcelable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@Entity(tableName = "conversations") @Entity(tableName = "conversations")
@Parcelize
data class VkConversation( data class VkConversation(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
val id: Int, val id: Int,
@@ -18,8 +21,9 @@ data class VkConversation(
val outRead: Int, val outRead: Int,
val isMarkedUnread: Boolean, val isMarkedUnread: Boolean,
val lastMessageId: Int, val lastMessageId: Int,
val unreadCount: Int? val unreadCount: Int?,
) { val membersCount: Int?
) : Parcelable {
@Ignore @Ignore
var lastMessage: VkMessage? = null var lastMessage: VkMessage? = null
@@ -1,16 +1,19 @@
package com.meloda.fast.api.model package com.meloda.fast.api.model
import android.os.Parcelable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@Entity(tableName = "groups") @Entity(tableName = "groups")
@Parcelize
data class VkGroup( data class VkGroup(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
val id: Int, val id: Int,
val name: String, val name: String,
val screenName: String, val screenName: String,
val photo200: String? val photo200: String?
) { ): Parcelable {
override fun toString() = name.trim() 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 package com.meloda.fast.api.model
import android.os.Parcelable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.meloda.fast.api.model.attachments.VkAttachment import com.meloda.fast.api.model.attachments.VkAttachment
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Entity(tableName = "messages") @Entity(tableName = "messages")
@Parcelize
data class VkMessage( data class VkMessage(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
val id: Int, val id: Int,
val text: String?, val text: String? = null,
val isOut: Boolean, val isOut: Boolean,
val peerId: Int, val peerId: Int,
val fromId: Int, val fromId: Int,
val date: Int, val date: Int,
val action: String?, val randomId: Int,
val actionMemberId: Int?, val action: String? = null,
val actionText: String?, val actionMemberId: Int? = null,
val actionConversationMessageId: Int?, val actionText: String? = null,
val actionMessage: String?, val actionConversationMessageId: Int? = null,
val geoType: String? val actionMessage: String? = null,
) { val geoType: String? = null
) : Parcelable {
@IgnoredOnParcel
@Ignore @Ignore
var forwards: List<VkMessage>? = null var forwards: List<VkMessage>? = null
@IgnoredOnParcel
@Ignore @Ignore
var attachments: List<VkAttachment>? = null var attachments: List<VkAttachment>? = null
fun isPeerChat() = peerId > 2_000_000_000
fun isUser() = fromId > 0 fun isUser() = fromId > 0
fun isGroup() = fromId < 0 fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation) = conversation.outRead < id
fun getPreparedAction(): Action? { fun getPreparedAction(): Action? {
if (action == null) return null if (action == null) return null
return Action.parse(action) 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) { enum class Action(val value: String) {
CHAT_CREATE("chat_create"), CHAT_CREATE("chat_create"),
CHAT_PHOTO_UPDATE("chat_photo_update"), CHAT_PHOTO_UPDATE("chat_photo_update"),
@@ -1,9 +1,12 @@
package com.meloda.fast.api.model package com.meloda.fast.api.model
import android.os.Parcelable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@Entity(tableName = "users") @Entity(tableName = "users")
@Parcelize
data class VkUser( data class VkUser(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
val id: Int, val id: Int,
@@ -11,7 +14,7 @@ data class VkUser(
val lastName: String, val lastName: String,
val online: Boolean, val online: Boolean,
val photo200: String? val photo200: String?
) { ) : Parcelable {
override fun toString() = "$firstName $lastName".trim() 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, outRead = outRead,
isMarkedUnread = isMarkedUnread, isMarkedUnread = isMarkedUnread,
lastMessageId = lastMessageId, lastMessageId = lastMessageId,
unreadCount = unreadCount unreadCount = unreadCount,
membersCount = chatSettings?.membersCount
).apply { this.lastMessage = lastMessage } ).apply { this.lastMessage = lastMessage }
@Parcelize @Parcelize
@@ -40,6 +40,7 @@ data class BaseVkMessage(
peerId = peerId, peerId = peerId,
fromId = fromId, fromId = fromId,
date = date, date = date,
randomId = randomId,
action = action?.type, action = action?.type,
actionMemberId = action?.memberId, actionMemberId = action?.memberId,
actionText = action?.text, actionText = action?.text,
@@ -6,16 +6,21 @@ object VKUrls {
const val API = "https://api.vk.com/method" const val API = "https://api.vk.com/method"
object Auth { object Auth {
const val directAuth = "$OAUTH/token" const val DirectAuth = "$OAUTH/token"
const val sendSms = "$API/auth.validatePhone" const val SendSms = "$API/auth.validatePhone"
} }
object Conversations { object Conversations {
const val get = "$API/messages.getConversations" const val Get = "$API/messages.getConversations"
} }
object Users { 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 { interface AuthRepo {
@GET(VKUrls.Auth.directAuth) @GET(VKUrls.Auth.DirectAuth)
suspend fun auth(@QueryMap param: Map<String, String?>): Answer<ResponseAuthDirect> 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> suspend fun sendSms(@Query("sid") validationSid: String): Answer<ResponseSendSms>
} }
@@ -11,7 +11,7 @@ import retrofit2.http.POST
interface ConversationsRepo { interface ConversationsRepo {
@FormUrlEncoded @FormUrlEncoded
@POST(VKUrls.Conversations.get) @POST(VKUrls.Conversations.Get)
suspend fun getAllChats(@FieldMap params: Map<String, String>): Answer<ApiResponse<ConversationsGetResponse>> suspend fun getAllChats(@FieldMap params: Map<String, String>): Answer<ApiResponse<ConversationsGetResponse>>
} }
@@ -1,4 +1,21 @@
package com.meloda.fast.api.network.repo 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 { 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 { interface UsersRepo {
@FormUrlEncoded @FormUrlEncoded
@POST(VKUrls.Users.getById) @POST(VKUrls.Users.GetById)
suspend fun getById( suspend fun getById(
@FieldMap params: Map<String, String>? @FieldMap params: Map<String, String>?
): Answer<ApiResponse<List<BaseVkUser>>> ): Answer<ApiResponse<List<BaseVkUser>>>
@@ -1,7 +1,6 @@
package com.meloda.fast.api.network.request package com.meloda.fast.api.network.request
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -11,7 +10,6 @@ data class ConversationsGetRequest(
val fields: String = "", val fields: String = "",
val filter: String = "all", val filter: String = "all",
val extended: Boolean? = true, val extended: Boolean? = true,
@SerializedName("start_message_id")
val startMessageId: Int? = null val startMessageId: Int? = null
) : Parcelable { ) : 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) protected var inflater: LayoutInflater = LayoutInflater.from(context)
var itemClickListener: OnItemClickListener? = null var itemClickListener: ((position: Int) -> Unit) = {}
var itemLongClickListener: OnItemLongClickListener? = null var itemLongClickListener: ((position: Int) -> Boolean) = { false }
open fun destroy() { open fun destroy() {}
itemClickListener = null
itemLongClickListener = null
}
override fun getItem(position: Int): Item { override fun getItem(position: Int): Item {
return values[position] 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) { fun add(position: Int, item: Item) {
values.add(position, item) values.add(position, item)
cleanValues.add(position, item) cleanValues.add(position, item)
@@ -103,26 +109,23 @@ abstract class BaseAdapter<Item, VH : BaseHolder>(
onBindItemViewHolder(holder, position) onBindItemViewHolder(holder, position)
} }
private fun onBindItemViewHolder(holder: VH, position: Int) {
initListeners(holder.itemView, position)
holder.bind(position)
}
protected fun initListeners(itemView: View, position: Int) { protected fun initListeners(itemView: View, position: Int) {
if (itemView is AdapterView<*>) return if (itemView is AdapterView<*>) return
itemView.setOnClickListener { itemView.setOnClickListener { itemClickListener.invoke(position) }
itemClickListener?.onItemClick(position) itemView.setOnLongClickListener { itemLongClickListener.invoke(position) }
}
itemView.setOnLongClickListener {
itemLongClickListener?.onItemLongClick(position)
return@setOnLongClickListener itemClickListener == null
}
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return values.size return values.size
} }
private fun onBindItemViewHolder(holder: VH, position: Int) { val lastPosition
initListeners(holder.itemView, position) get() = itemCount - 1
holder.bind(position)
}
} }
@@ -18,7 +18,7 @@ import com.meloda.fast.database.dao.UsersDao
VkUser::class, VkUser::class,
VkGroup::class VkGroup::class
], ],
version = 11, version = 13,
exportSchema = false exportSchema = false
) )
abstract class AppDatabase : RoomDatabase() { 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.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
@@ -28,7 +28,7 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@Module @Module
class VKModules { object NetworkModule {
@Singleton @Singleton
@Provides @Provides
@@ -77,6 +77,10 @@ class VKModules {
fun provideUsersRepo(retrofit: Retrofit): UsersRepo = fun provideUsersRepo(retrofit: Retrofit): UsersRepo =
retrofit.create(UsersRepo::class.java) retrofit.create(UsersRepo::class.java)
@Provides
fun provideMessagesRepo(retrofit: Retrofit): MessagesRepo =
retrofit.create(MessagesRepo::class.java)
@Provides @Provides
@Singleton @Singleton
fun provideAuthDataSource( fun provideAuthDataSource(
@@ -1,4 +1,4 @@
package com.meloda.fast.screens.messages package com.meloda.fast.screens.conversations
import android.content.Context import android.content.Context
import android.text.SpannableString 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.BaseAdapter
import com.meloda.fast.base.adapter.BindingHolder import com.meloda.fast.base.adapter.BindingHolder
import com.meloda.fast.databinding.ItemConversationBinding import com.meloda.fast.databinding.ItemConversationBinding
import java.text.SimpleDateFormat import com.meloda.fast.util.TimeUtils
class ConversationsAdapter constructor( class ConversationsAdapter constructor(
context: Context, context: Context,
@@ -76,9 +76,9 @@ class ConversationsAdapter constructor(
} else null } else null
val avatar = when { val avatar = when {
chatUser != null && !chatUser.photo200.isNullOrBlank() -> chatUser.photo200 conversation.isUser() && chatUser != null && !chatUser.photo200.isNullOrBlank() -> chatUser.photo200
chatGroup != null && !chatGroup.photo200.isNullOrBlank() -> chatGroup.photo200 conversation.isGroup() && chatGroup != null && !chatGroup.photo200.isNullOrBlank() -> chatGroup.photo200
!conversation.photo200.isNullOrBlank() -> conversation.photo200 conversation.isChat() && !conversation.photo200.isNullOrBlank() -> conversation.photo200
else -> null else -> null
} }
@@ -127,11 +127,11 @@ class ConversationsAdapter constructor(
message = message message = message
) else null ) else null
val messageText = if (actionMessage != null || val messageText = (if (actionMessage != null ||
forwardsMessage != null || forwardsMessage != null ||
attachmentText != null attachmentText != null
) "" ) ""
else message.text ?: "[no_message]" else message.text ?: "[no_message]").run { VkUtils.prepareMessageText(this) }
val coloredMessage = actionMessage ?: attachmentText ?: forwardsMessage ?: "" val coloredMessage = actionMessage ?: attachmentText ?: forwardsMessage ?: ""
@@ -165,7 +165,7 @@ class ConversationsAdapter constructor(
binding.title.text = binding.title.text =
getItem(position).title ?: chatUser?.toString() ?: chatGroup?.name ?: "..." 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( binding.container.background = if (conversation.isUnread()) ContextCompat.getDrawable(
context, context,
@@ -1,10 +1,12 @@
package com.meloda.fast.screens.messages package com.meloda.fast.screens.conversations
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.viewbinding.library.fragment.viewBinding import android.viewbinding.library.fragment.viewBinding
import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import coil.load import coil.load
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.meloda.fast.R 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.databinding.FragmentConversationsBinding
import com.meloda.fast.util.AndroidUtils import com.meloda.fast.util.AndroidUtils
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class ConversationsFragment : class ConversationsFragment :
@@ -39,7 +40,10 @@ class ConversationsFragment :
prepareViews() prepareViews()
adapter = ConversationsAdapter(requireContext(), mutableListOf()) adapter = ConversationsAdapter(requireContext(), mutableListOf()).also {
it.itemClickListener = this::onItemClick
it.itemLongClickListener = this::onItemLongClick
}
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
viewModel.loadConversations() viewModel.loadConversations()
@@ -86,9 +90,7 @@ class ConversationsFragment :
private fun prepareRefreshLayout() { private fun prepareRefreshLayout() {
with(binding.refreshLayout) { with(binding.refreshLayout) {
setProgressViewOffset( setProgressViewOffset(
true, true, progressViewStartOffset, progressViewEndOffset
AndroidUtils.px(40).roundToInt(),
AndroidUtils.px(96).roundToInt()
) )
setProgressBackgroundColorSchemeColor( setProgressBackgroundColorSchemeColor(
AndroidUtils.getThemeAttrColor( AndroidUtils.getThemeAttrColor(
@@ -107,10 +109,7 @@ class ConversationsFragment :
} }
private fun refreshConversations(event: ConversationsLoaded) { private fun refreshConversations(event: ConversationsLoaded) {
// adapter.profiles.clear()
adapter.profiles += event.profiles adapter.profiles += event.profiles
// adapter.groups.clear()
adapter.groups += event.groups adapter.groups += event.groups
fillRecyclerView(event.conversations) fillRecyclerView(event.conversations)
@@ -122,4 +121,24 @@ class ConversationsFragment :
adapter.notifyItemRangeChanged(0, adapter.itemCount) 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 androidx.lifecycle.viewModelScope
import com.meloda.fast.api.UserConfig import com.meloda.fast.api.UserConfig
@@ -31,7 +31,7 @@ class ConversationsViewModel @Inject constructor(
dataSource.getAllChats( dataSource.getAllChats(
ConversationsGetRequest( ConversationsGetRequest(
count = 30, count = 30,
// offset = 37, // offset = 177,
extended = true, extended = true,
fields = "${VKConstants.USER_FIELDS},${VKConstants.GROUP_FIELDS}" fields = "${VKConstants.USER_FIELDS},${VKConstants.GROUP_FIELDS}"
) )
@@ -49,9 +49,6 @@ class ConversationsViewModel @Inject constructor(
baseGroup.asVkGroup().let { group -> groups[group.id] = group } 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( sendEvent(
ConversationsLoaded( ConversationsLoaded(
count = response.count, count = response.count,
@@ -71,12 +68,8 @@ class ConversationsViewModel @Inject constructor(
val er = it val er = it
throw it throw it
}, },
onStart = { onStart = { sendEvent(StartProgressEvent) },
sendEvent(StartProgressEvent) onEnd = { sendEvent(StopProgressEvent) })
},
onEnd = {
sendEvent(StopProgressEvent)
})
} }
fun loadProfileUser() = viewModelScope.launch { fun loadProfileUser() = viewModelScope.launch {
@@ -96,7 +89,7 @@ class ConversationsViewModel @Inject constructor(
data class ConversationsLoaded( data class ConversationsLoaded(
val count: Int, val count: Int,
val unreadCount: Int, val unreadCount: Int?,
val conversations: List<VkConversation>, val conversations: List<VkConversation>,
val profiles: HashMap<Int, VkUser>, val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup> 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 return color
} }
} }
@@ -1,5 +1,8 @@
package com.meloda.fast.util package com.meloda.fast.util
import android.content.Context
import com.meloda.fast.R
import java.text.SimpleDateFormat
import java.util.* import java.util.*
object TimeUtils { object TimeUtils {
@@ -14,4 +17,58 @@ object TimeUtils {
}.timeInMillis }.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 mBoundedWidth: Int
private var mBoundedHeight: Int private var mBoundedHeight: Int
constructor(context: Context?) : super(context) { constructor(context: Context) : super(context) {
mBoundedWidth = 0 mBoundedWidth = 0
mBoundedHeight = 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,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@@ -103,4 +105,5 @@
app:tint="@color/a2_700" app:tint="@color/a2_700"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </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 <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?selectableItemBackground" android:layout_marginVertical="4dp"
android:orientation="horizontal" android:orientation="horizontal">
android:paddingVertical="4dp">
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/container" android:id="@+id/container"
@@ -30,13 +29,13 @@
android:id="@+id/avatar" android:id="@+id/avatar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:src="@tools:sample/avatars" tools:src="@tools:sample/avatars" />
tools:visibility="gone" />
<FrameLayout <FrameLayout
android:id="@+id/avatarPlaceholder" android:id="@+id/avatarPlaceholder"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:visibility="gone">
<com.meloda.fast.widget.CircleImageView <com.meloda.fast.widget.CircleImageView
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -51,6 +50,28 @@
</FrameLayout> </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 <FrameLayout
android:id="@+id/service" android:id="@+id/service"
@@ -90,31 +111,6 @@
app:tint="@color/n2_0" /> app:tint="@color/n2_0" />
</FrameLayout> </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> </FrameLayout>
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat
@@ -221,6 +217,11 @@
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?selectableItemBackground" />
</FrameLayout> </FrameLayout>
</layout> </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 <fragment
android:id="@+id/conversationsFragment" android:id="@+id/conversationsFragment"
android:name="com.meloda.fast.screens.messages.ConversationsFragment" android:name="com.meloda.fast.screens.conversations.ConversationsFragment"
android:label="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> </navigation>
+4
View File
@@ -9,13 +9,17 @@
<color name="a2_700">@android:color/system_accent2_700</color> <color name="a2_700">@android:color/system_accent2_700</color>
<color name="a3_200">@android:color/system_accent3_200</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_50">@android:color/system_neutral1_50</color>
<color name="n1_100">@android:color/system_neutral1_100</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="n1_900">@android:color/system_neutral1_900</color>
<color name="n2_0">@android:color/system_neutral2_0</color> <color name="n2_0">@android:color/system_neutral2_0</color>
<color name="n2_10">@android:color/system_neutral2_10</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> <color name="n2_500">@android:color/system_neutral2_500</color>
</resources> </resources>
+4 -2
View File
@@ -9,15 +9,17 @@
<color name="a2_700">#414757</color> <color name="a2_700">#414757</color>
<color name="a3_200">#DEBAE5</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_50">#F1F1F1</color>
<color name="n1_100">#E2E1E5</color> <color name="n1_100">#E2E1E5</color>
<color name="n1_800">#303033</color>
<color name="n1_900">#1B1B1D</color> <color name="n1_900">#1B1B1D</color>
<color name="n2_0">#FFFFFF</color> <color name="n2_0">#FFFFFF</color>
<color name="n2_10">#FDFBFE</color> <color name="n2_10">#FDFBFE</color>
<color name="n2_100">#E0E2EB</color>
<color name="n2_500">#74767D</color> <color name="n2_500">#74767D</color>
<!-- Dark Theme Colors -->
</resources> </resources>
+11
View File
@@ -34,4 +34,15 @@
<string name="messages_self_destructed">Messages self-destructed</string> <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> </resources>