diff --git a/app/build.gradle b/app/build.gradle
index e13d187..45d4c9e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -139,4 +139,7 @@ dependencies {
// agora
implementation "io.agora.rtc:voice-sdk:4.1.0-1"
implementation 'io.agora.rtm:rtm-sdk:1.5.3'
+
+ // sound visualizer
+ implementation "com.gauravk.audiovisualizer:audiovisualizer:0.9.2"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9e573ce..8c21014 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -45,6 +45,9 @@
+
+
+
(
private lateinit var imm: InputMethodManager
private val handler = Handler(Looper.getMainLooper())
- private lateinit var searchChannelAdapter: MessageSelectRecipientAdapter
+ private lateinit var searchChannelAdapter: SelectMessageRecipientAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -106,7 +106,7 @@ class ExplorerFragment : BaseFragment(
}
private fun setupSearchChannelView() {
- searchChannelAdapter = MessageSelectRecipientAdapter {
+ searchChannelAdapter = SelectMessageRecipientAdapter {
hideKeyboard()
val intent = Intent(requireContext(), UserProfileActivity::class.java)
intent.putExtra(Constants.EXTRA_USER_ID, it.id)
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveApi.kt
index a7bff5d..df4b5dd 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveApi.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveApi.kt
@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.live.room.StartLiveRequest
import kr.co.vividnext.sodalive.live.room.create.CreateLiveRoomResponse
import kr.co.vividnext.sodalive.live.room.create.GetRecentRoomInfoResponse
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.live.room.donation.DeleteLiveRoomDonationMessage
import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationStatusResponse
import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationTotalResponse
@@ -182,4 +183,9 @@ interface LiveApi {
@Path("id") id: Long,
@Header("Authorization") authHeader: String
): Single>
+
+ @GET("/live/room/recent_visit_room/users")
+ fun recentVisitRoomUsers(
+ @Header("Authorization") authHeader: String
+ ): Single>>
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt
index fec9412..69fd573 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt
@@ -208,4 +208,6 @@ class LiveRepository(
roomId: Long,
token: String
) = api.donationStatus(roomId, authHeader = token)
+
+ fun recentVisitRoomUsers(token: String) = api.recentVisitRoomUsers(authHeader = token)
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/GetMessageResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/GetMessageResponse.kt
new file mode 100644
index 0000000..a5f84ee
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/GetMessageResponse.kt
@@ -0,0 +1,40 @@
+package kr.co.vividnext.sodalive.message
+
+import android.os.Parcelable
+import com.google.gson.annotations.SerializedName
+import kotlinx.parcelize.Parcelize
+
+data class GetVoiceMessageResponse(
+ @SerializedName("totalCount") val totalCount: Int,
+ @SerializedName("items") val items: List
+) {
+ data class VoiceMessageItem(
+ @SerializedName("messageId") val messageId: Long,
+ @SerializedName("senderId") val senderId: Long,
+ @SerializedName("senderNickname") val senderNickname: String,
+ @SerializedName("senderProfileImageUrl") val senderProfileImageUrl: String,
+ @SerializedName("recipientNickname") val recipientNickname: String,
+ @SerializedName("recipientProfileImageUrl") val recipientProfileImageUrl: String,
+ @SerializedName("voiceMessageUrl") val voiceMessageUrl: String,
+ @SerializedName("date") val date: String,
+ @SerializedName("isKept") val isKept: Boolean
+ )
+}
+
+data class GetTextMessageResponse(
+ @SerializedName("totalCount") val totalCount: Int,
+ @SerializedName("items") val items: List
+) {
+ @Parcelize
+ data class TextMessageItem(
+ @SerializedName("messageId") val messageId: Long,
+ @SerializedName("senderId") val senderId: Long,
+ @SerializedName("senderNickname") val senderNickname: String,
+ @SerializedName("senderProfileImageUrl") val senderProfileImageUrl: String,
+ @SerializedName("recipientNickname") val recipientNickname: String,
+ @SerializedName("recipientProfileImageUrl") val recipientProfileImageUrl: String,
+ @SerializedName("textMessage") val textMessage: String,
+ @SerializedName("date") val date: String,
+ @SerializedName("isKept") val isKept: Boolean
+ ) : Parcelable
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/MessageApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageApi.kt
new file mode 100644
index 0000000..7fbc9cc
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageApi.kt
@@ -0,0 +1,100 @@
+package kr.co.vividnext.sodalive.message
+
+import io.reactivex.rxjava3.core.Single
+import kr.co.vividnext.sodalive.common.ApiResponse
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.Multipart
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Part
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface MessageApi {
+ @POST("/message/send/text")
+ fun sendTextMessage(
+ @Body request: SendTextMessageRequest,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @POST("/message/send/voice")
+ @Multipart
+ fun sendVoiceMessage(
+ @Part voiceFile: MultipartBody.Part,
+ @Part("request") request: RequestBody,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/message/sent/text")
+ fun getSentTextMessage(
+ @Query("timezone") timezone: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/message/received/text")
+ fun getReceivedTextMessage(
+ @Query("timezone") timezone: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/message/keep/text")
+ fun getKeepTextMessage(
+ @Query("timezone") timezone: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/message/sent/voice")
+ fun getSentVoiceMessage(
+ @Query("timezone") timezone: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/message/received/voice")
+ fun getReceivedVoiceMessage(
+ @Query("timezone") timezone: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/message/keep/voice")
+ fun getKeepVoiceMessage(
+ @Query("timezone") timezone: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @DELETE("/message/{messageId}")
+ fun deleteMessage(
+ @Path("messageId") messageId: Long,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @PUT("/message/keep/text/{id}")
+ fun keepTextMessage(
+ @Path("id") id: Long,
+ @Body container: String = "aos",
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @PUT("/message/keep/voice/{id}")
+ fun keepVoiceMessage(
+ @Path("id") id: Long,
+ @Body container: String = "aos",
+ @Header("Authorization") authHeader: String
+ ): Single>
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/MessageBox.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageBox.kt
new file mode 100644
index 0000000..da26a4a
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageBox.kt
@@ -0,0 +1,14 @@
+package kr.co.vividnext.sodalive.message
+
+import com.google.gson.annotations.SerializedName
+
+enum class MessageBox {
+ @SerializedName("SENT")
+ SENT,
+
+ @SerializedName("RECEIVE")
+ RECEIVE,
+
+ @SerializedName("KEEP")
+ KEEP
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/MessageFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageFragment.kt
index 8604483..1661312 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/message/MessageFragment.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageFragment.kt
@@ -1,7 +1,65 @@
package kr.co.vividnext.sodalive.message
+import android.os.Bundle
+import android.view.View
+import com.google.android.material.tabs.TabLayout
+import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentMessageBinding
+import kr.co.vividnext.sodalive.message.text.TextMessageFragment
+import kr.co.vividnext.sodalive.message.voice.VoiceMessageFragment
class MessageFragment : BaseFragment(FragmentMessageBinding::inflate) {
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupView()
+ changeFragment("message")
+ }
+
+ private fun setupView() {
+ val tabs = binding.tabs
+ tabs.addTab(tabs.newTab().setText("문자").setTag("message"))
+ tabs.addTab(tabs.newTab().setText("음성").setTag("voice"))
+
+ tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: TabLayout.Tab) {
+ val tag = tab.tag as String
+ changeFragment(tag)
+ }
+
+ override fun onTabUnselected(tab: TabLayout.Tab) {
+ }
+
+ override fun onTabReselected(tab: TabLayout.Tab) {
+ }
+ })
+ }
+
+ private fun changeFragment(tag: String) {
+ val fragmentManager = childFragmentManager
+ val fragmentTransaction = fragmentManager.beginTransaction()
+
+ val currentFragment = fragmentManager.primaryNavigationFragment
+ if (currentFragment != null) {
+ fragmentTransaction.hide(currentFragment)
+ }
+
+ var fragment = fragmentManager.findFragmentByTag(tag)
+ if (fragment == null) {
+ fragment = if (tag == "message") {
+ TextMessageFragment()
+ } else {
+ VoiceMessageFragment()
+ }
+
+ fragmentTransaction.add(R.id.container, fragment, tag)
+ } else {
+ fragmentTransaction.show(fragment)
+ }
+
+ fragmentTransaction.setPrimaryNavigationFragment(fragment)
+ fragmentTransaction.setReorderingAllowed(true)
+ fragmentTransaction.commitNow()
+ }
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/MessageRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageRepository.kt
new file mode 100644
index 0000000..1fcd63e
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/MessageRepository.kt
@@ -0,0 +1,124 @@
+package kr.co.vividnext.sodalive.message
+
+import io.reactivex.rxjava3.core.Single
+import kr.co.vividnext.sodalive.common.ApiResponse
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import java.util.TimeZone
+
+class MessageRepository(private val api: MessageApi) {
+ fun sendTextMessage(request: SendTextMessageRequest, token: String): Single> {
+ return api.sendTextMessage(request, authHeader = token)
+ }
+
+ fun sendVoiceMessage(
+ voiceFile: MultipartBody.Part,
+ request: RequestBody,
+ token: String
+ ): Single> {
+ return api.sendVoiceMessage(
+ voiceFile,
+ request,
+ authHeader = token
+ )
+ }
+
+ fun getSentTextMessage(
+ page: Int,
+ size: Int,
+ token: String
+ ): Single> {
+ return api.getSentTextMessage(
+ TimeZone.getDefault().id,
+ page,
+ size,
+ authHeader = token
+ )
+ }
+
+ fun getReceivedTextMessage(
+ page: Int,
+ size: Int,
+ token: String
+ ): Single> {
+ return api.getReceivedTextMessage(
+ TimeZone.getDefault().id,
+ page,
+ size,
+ authHeader = token
+ )
+ }
+
+ fun keepTextMessage(messageId: Long, token: String): Single> {
+ return api.keepTextMessage(
+ messageId,
+ authHeader = token
+ )
+ }
+
+ fun getKeepTextMessage(
+ page: Int,
+ size: Int,
+ token: String
+ ): Single> {
+ return api.getKeepTextMessage(
+ TimeZone.getDefault().id,
+ page,
+ size,
+ authHeader = token
+ )
+ }
+
+ fun getSentVoiceMessage(
+ page: Int,
+ size: Int,
+ token: String
+ ): Single> {
+ return api.getSentVoiceMessage(
+ TimeZone.getDefault().id,
+ page,
+ size,
+ authHeader = token
+ )
+ }
+
+ fun getReceivedVoiceMessage(
+ page: Int,
+ size: Int,
+ token: String
+ ): Single> {
+ return api.getReceivedVoiceMessage(
+ TimeZone.getDefault().id,
+ page,
+ size,
+ authHeader = token
+ )
+ }
+
+ fun getKeepVoiceMessage(
+ page: Int,
+ size: Int,
+ token: String
+ ): Single> {
+ return api.getKeepVoiceMessage(
+ TimeZone.getDefault().id,
+ page,
+ size,
+ authHeader = token
+ )
+ }
+
+ fun deleteMessage(
+ messageId: Long,
+ token: String
+ ): Single> {
+ return api.deleteMessage(messageId, authHeader = token)
+ }
+
+ fun keepVoiceMessage(messageId: Long, token: String): Single> {
+ return api.keepVoiceMessage(
+ messageId,
+ authHeader = token
+ )
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientActivity.kt
new file mode 100644
index 0000000..c0348d5
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientActivity.kt
@@ -0,0 +1,91 @@
+package kr.co.vividnext.sodalive.message
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.graphics.Rect
+import android.os.Bundle
+import android.view.View
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.jakewharton.rxbinding4.widget.textChanges
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.base.BaseActivity
+import kr.co.vividnext.sodalive.common.Constants
+import kr.co.vividnext.sodalive.databinding.ActivitySelectMessageRecipientBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+import org.koin.android.ext.android.inject
+import java.util.concurrent.TimeUnit
+
+class SelectMessageRecipientActivity : BaseActivity(
+ ActivitySelectMessageRecipientBinding::inflate
+) {
+
+ private val viewModel: SelectMessageRecipientViewModel by inject()
+
+ private lateinit var adapter: SelectMessageRecipientAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ bindData()
+ viewModel.searchUser("")
+ }
+
+ override fun setupView() {
+ binding.toolbar.tvBack.text = "받는 사람 검색"
+ binding.toolbar.tvBack.setOnClickListener { finish() }
+
+ val recyclerView = binding.rvRecipient
+
+ adapter = SelectMessageRecipientAdapter {
+ val intent = Intent()
+ intent.putExtra(Constants.EXTRA_SELECT_RECIPIENT, it)
+ setResult(RESULT_OK, intent)
+ finish()
+ }
+
+ recyclerView.layoutManager = LinearLayoutManager(
+ applicationContext,
+ LinearLayoutManager.VERTICAL,
+ false
+ )
+
+ recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+
+ outRect.left = 13.3f.dpToPx().toInt()
+ outRect.right = 13.3f.dpToPx().toInt()
+ outRect.top = 13.3f.dpToPx().toInt()
+ outRect.bottom = 13.3f.dpToPx().toInt()
+ }
+ })
+
+ recyclerView.adapter = adapter
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ private fun bindData() {
+ compositeDisposable.add(
+ binding.etSearchNickname.textChanges().skip(1)
+ .debounce(500, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribeOn(Schedulers.io())
+ .subscribe {
+ viewModel.searchUser(it.toString())
+ }
+ )
+
+ viewModel.searchUserLiveData.observe(this) {
+ adapter.items.clear()
+ adapter.items.addAll(it)
+ adapter.notifyDataSetChanged()
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/MessageSelectRecipientAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientAdapter.kt
similarity index 93%
rename from app/src/main/java/kr/co/vividnext/sodalive/message/MessageSelectRecipientAdapter.kt
rename to app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientAdapter.kt
index 4971ec6..8ffaca7 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/message/MessageSelectRecipientAdapter.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientAdapter.kt
@@ -10,9 +10,9 @@ import kr.co.vividnext.sodalive.databinding.ItemSelectRecipientBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
-class MessageSelectRecipientAdapter(
+class SelectMessageRecipientAdapter(
private val onClickItem: (GetRoomDetailUser) -> Unit
-) : RecyclerView.Adapter() {
+) : RecyclerView.Adapter() {
inner class ViewHolder(
private val binding: ItemSelectRecipientBinding
) : RecyclerView.ViewHolder(binding.root) {
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientViewModel.kt
new file mode 100644
index 0000000..954a34f
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientViewModel.kt
@@ -0,0 +1,65 @@
+package kr.co.vividnext.sodalive.message
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.orhanobut.logger.Logger
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.base.BaseViewModel
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.live.LiveRepository
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
+import kr.co.vividnext.sodalive.user.UserRepository
+
+class SelectMessageRecipientViewModel(
+ private val liveRepository: LiveRepository,
+ private val userRepository: UserRepository
+) : BaseViewModel() {
+
+ private val _searchUserLiveData =
+ MutableLiveData>()
+ val searchUserLiveData: LiveData>
+ get() = _searchUserLiveData
+
+ fun searchUser(nickname: String) {
+ if (nickname.length > 1) {
+ compositeDisposable.add(
+ userRepository.searchUser(nickname, "Bearer ${SharedPreferenceManager.token}")
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ if (it.success && it.data != null) {
+ _searchUserLiveData.postValue(it.data!!)
+ } else {
+ _searchUserLiveData.postValue(emptyList())
+ }
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _searchUserLiveData.postValue(emptyList())
+ }
+ )
+ )
+ } else {
+ compositeDisposable.add(
+ liveRepository.recentVisitRoomUsers("Bearer ${SharedPreferenceManager.token}")
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ if (it.success && it.data != null) {
+ _searchUserLiveData.postValue(it.data!!)
+ } else {
+ _searchUserLiveData.postValue(emptyList())
+ }
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _searchUserLiveData.postValue(emptyList())
+ }
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/SendMessageRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/SendMessageRequest.kt
new file mode 100644
index 0000000..9d260b4
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/SendMessageRequest.kt
@@ -0,0 +1,14 @@
+package kr.co.vividnext.sodalive.message
+
+import com.google.gson.annotations.SerializedName
+
+data class SendVoiceMessageRequest(
+ @SerializedName("recipientId") val recipientId: Long,
+ @SerializedName("container") val container: String = "aos"
+)
+
+data class SendTextMessageRequest(
+ @SerializedName("recipientId") val recipientId: Long,
+ @SerializedName("textMessage") val textMessage: String,
+ @SerializedName("container") val container: String = "aos"
+)
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageAdapter.kt
new file mode 100644
index 0000000..97d46bb
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageAdapter.kt
@@ -0,0 +1,63 @@
+package kr.co.vividnext.sodalive.message.text
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import coil.transform.RoundedCornersTransformation
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.databinding.ItemTextMessageBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+import kr.co.vividnext.sodalive.message.GetTextMessageResponse
+
+class TextMessageAdapter(
+ private val onItemClick: (GetTextMessageResponse.TextMessageItem) -> Unit
+) : RecyclerView.Adapter() {
+
+ inner class ViewHolder(
+ private val binding: ItemTextMessageBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(item: GetTextMessageResponse.TextMessageItem) {
+ if (SharedPreferenceManager.nickname == item.recipientNickname) {
+ binding.ivProfile.load(item.senderProfileImageUrl) {
+ crossfade(true)
+ placeholder(R.drawable.bg_placeholder)
+ transformations(RoundedCornersTransformation(23.4f.dpToPx()))
+ }
+
+ binding.tvNickname.text = item.senderNickname
+ } else {
+ binding.ivProfile.load(item.recipientProfileImageUrl) {
+ crossfade(true)
+ placeholder(R.drawable.bg_placeholder)
+ transformations(RoundedCornersTransformation(23.4f.dpToPx()))
+ }
+
+ binding.tvNickname.text = item.recipientNickname
+ }
+
+ binding.tvDate.text = item.date
+ binding.tvMessage.text = item.textMessage
+
+ binding.root.setOnClickListener { onItemClick(item) }
+ }
+ }
+
+ val items = mutableListOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
+ ItemTextMessageBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(items[position])
+ }
+
+ override fun getItemCount() = items.size
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageDetailActivity.kt
new file mode 100644
index 0000000..fb76600
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageDetailActivity.kt
@@ -0,0 +1,11 @@
+package kr.co.vividnext.sodalive.message.text
+
+import kr.co.vividnext.sodalive.base.BaseActivity
+import kr.co.vividnext.sodalive.databinding.ActivityTextMessageDetailBinding
+
+class TextMessageDetailActivity : BaseActivity(
+ ActivityTextMessageDetailBinding::inflate
+) {
+ override fun setupView() {
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageFragment.kt
new file mode 100644
index 0000000..4d2ce8a
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageFragment.kt
@@ -0,0 +1,229 @@
+package kr.co.vividnext.sodalive.message.text
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Rect
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.base.BaseFragment
+import kr.co.vividnext.sodalive.common.Constants
+import kr.co.vividnext.sodalive.common.LoadingDialog
+import kr.co.vividnext.sodalive.databinding.FragmentTextMessageBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+import kr.co.vividnext.sodalive.message.MessageBox
+import org.koin.android.ext.android.inject
+
+class TextMessageFragment : BaseFragment(
+ FragmentTextMessageBinding::inflate
+) {
+ private val viewModel: TextMessageViewModel by inject()
+
+ private lateinit var activityResultLauncher: ActivityResultLauncher
+ private lateinit var adapter: TextMessageAdapter
+
+ private lateinit var loadingDialog: LoadingDialog
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ activityResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ if (it.resultCode == Activity.RESULT_OK) {
+ viewModel.page = 1
+ viewModel.getMessages()
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupView()
+ bindData()
+
+ viewModel.getMessages()
+ }
+
+ private fun setupView() {
+ loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
+
+ binding.tvSent.setOnClickListener {
+ viewModel.selectMessageBox(MessageBox.SENT)
+ }
+
+ binding.tvReceive.setOnClickListener {
+ viewModel.selectMessageBox(MessageBox.RECEIVE)
+ }
+
+ binding.tvKeep.setOnClickListener {
+ viewModel.selectMessageBox(MessageBox.KEEP)
+ }
+
+ binding.ivWrite.setOnClickListener {
+ val intent = Intent(requireActivity(), TextMessageWriteActivity::class.java)
+ activityResultLauncher.launch(intent)
+ }
+
+ val recyclerView = binding.rvMessage
+ adapter = TextMessageAdapter {
+ val intent = Intent(requireActivity(), TextMessageDetailActivity::class.java)
+ intent.putExtra(Constants.EXTRA_TEXT_MESSAGE, it)
+ intent.putExtra(
+ Constants.EXTRA_MESSAGE_BOX,
+ viewModel.messageBoxLiveData.value!!.name
+ )
+ activityResultLauncher.launch(intent)
+ }
+
+ recyclerView.layoutManager = LinearLayoutManager(
+ requireContext(),
+ LinearLayoutManager.VERTICAL,
+ false
+ )
+
+ recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+
+ outRect.left = 13.3f.dpToPx().toInt()
+ outRect.right = 13.3f.dpToPx().toInt()
+ outRect.top = 13.3f.dpToPx().toInt()
+ outRect.bottom = 13.3f.dpToPx().toInt()
+ }
+ })
+
+ recyclerView.adapter = adapter
+
+ recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+
+ val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!!
+ .findLastCompletelyVisibleItemPosition()
+ val itemTotalCount = recyclerView.adapter!!.itemCount - 1
+
+ // 스크롤이 끝에 도달했는지 확인
+ if (!recyclerView.canScrollVertically(1) &&
+ lastVisibleItemPosition == itemTotalCount
+ ) {
+ viewModel.getMessages(viewModel.messageBoxLiveData.value!!)
+ }
+ }
+ })
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ private fun bindData() {
+ viewModel.toastLiveData.observe(viewLifecycleOwner) {
+ it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
+ }
+
+ viewModel.isLoading.observe(viewLifecycleOwner) {
+ if (it) {
+ loadingDialog.show(screenWidth, "문자 메시지를 불러오고 있습니다.")
+ } else {
+ loadingDialog.dismiss()
+ }
+ }
+
+ viewModel.getMessagesLiveData.observe(viewLifecycleOwner) {
+ if (viewModel.page - 1 == 1) {
+ adapter.items.clear()
+ }
+
+ if (adapter.items.size == 0 && it.isEmpty()) {
+ binding.rvMessage.visibility = View.GONE
+ binding.llNoItems.visibility = View.VISIBLE
+ } else {
+ binding.rvMessage.visibility = View.VISIBLE
+ binding.llNoItems.visibility = View.GONE
+ adapter.items.addAll(it)
+ adapter.notifyDataSetChanged()
+ }
+ }
+
+ viewModel.messageBoxLiveData.observe(viewLifecycleOwner) {
+ binding.tvSent.setBackgroundResource(R.drawable.bg_round_corner_16_7_transparent_777777)
+ binding.tvSent.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_777777
+ )
+ )
+
+ binding.tvReceive.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_777777
+ )
+ binding.tvReceive.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_777777
+ )
+ )
+
+ binding.tvKeep.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_777777
+ )
+ binding.tvKeep.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_777777
+ )
+ )
+
+ when (it) {
+ MessageBox.SENT -> {
+ binding.tvSent.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_9970ff
+ )
+ binding.tvSent.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_9970ff
+ )
+ )
+ }
+
+ MessageBox.RECEIVE -> {
+ binding.tvReceive.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_9970ff
+ )
+ binding.tvReceive.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_9970ff
+ )
+ )
+ }
+
+ MessageBox.KEEP -> {
+ binding.tvKeep.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_9970ff
+ )
+ binding.tvKeep.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_9970ff
+ )
+ )
+ }
+
+ else -> {
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageViewModel.kt
new file mode 100644
index 0000000..dec8d68
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageViewModel.kt
@@ -0,0 +1,105 @@
+package kr.co.vividnext.sodalive.message.text
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.orhanobut.logger.Logger
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.base.BaseViewModel
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.message.GetTextMessageResponse
+import kr.co.vividnext.sodalive.message.MessageBox
+import kr.co.vividnext.sodalive.message.MessageRepository
+
+class TextMessageViewModel(private val repository: MessageRepository) : BaseViewModel() {
+ private val _messageBoxLiveData = MutableLiveData(MessageBox.RECEIVE)
+ val messageBoxLiveData: LiveData
+ get() = _messageBoxLiveData
+
+ private val _getMessagesLiveData =
+ MutableLiveData>()
+ val getMessagesLiveData: LiveData>
+ get() = _getMessagesLiveData
+
+ private val _toastLiveData = MutableLiveData()
+ val toastLiveData: LiveData
+ get() = _toastLiveData
+
+ var page = 1
+ var pageSize = 10
+ private var totalCount = 0
+
+ private var _isLoading = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
+ fun selectMessageBox(messageBox: MessageBox) {
+ if (messageBox != _messageBoxLiveData.value!!) {
+ page = 1
+ _messageBoxLiveData.postValue(messageBox)
+ getMessages(messageBox)
+ }
+ }
+
+ fun getMessages(messageBox: MessageBox = _messageBoxLiveData.value!!) {
+ if (!_isLoading.value!! && (page - 1 == 0 || totalCount > page * pageSize)) {
+ _isLoading.postValue(true)
+
+ val messageBoxObservable = when (messageBox) {
+ MessageBox.SENT -> {
+ repository.getSentTextMessage(
+ page - 1,
+ pageSize,
+ "Bearer ${SharedPreferenceManager.token}"
+ )
+ }
+
+ MessageBox.RECEIVE -> {
+ repository.getReceivedTextMessage(
+ page - 1,
+ pageSize,
+ "Bearer ${SharedPreferenceManager.token}"
+ )
+ }
+
+ MessageBox.KEEP -> {
+ repository.getKeepTextMessage(
+ page - 1,
+ pageSize,
+ "Bearer ${SharedPreferenceManager.token}"
+ )
+ }
+ }
+
+ compositeDisposable.add(
+ messageBoxObservable
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ if (it.success && it.data != null) {
+ totalCount = it.data.totalCount
+ _getMessagesLiveData.postValue(it.data.items)
+
+ page += 1
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
+ )
+ }
+ }
+ _isLoading.postValue(false)
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ _isLoading.postValue(false)
+ }
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteActivity.kt
new file mode 100644
index 0000000..8baff43
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteActivity.kt
@@ -0,0 +1,117 @@
+package kr.co.vividnext.sodalive.message.text
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.IntentCompat
+import com.jakewharton.rxbinding4.widget.textChanges
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.base.BaseActivity
+import kr.co.vividnext.sodalive.common.Constants
+import kr.co.vividnext.sodalive.common.LoadingDialog
+import kr.co.vividnext.sodalive.databinding.ActivityTextMessageWriteBinding
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
+import kr.co.vividnext.sodalive.message.SelectMessageRecipientActivity
+import org.koin.android.ext.android.inject
+import java.util.concurrent.TimeUnit
+
+class TextMessageWriteActivity : BaseActivity(
+ ActivityTextMessageWriteBinding::inflate
+) {
+ private val viewModel: TextMessageWriteViewModel by inject()
+
+ private lateinit var loadingDialog: LoadingDialog
+ private lateinit var activityResultLauncher: ActivityResultLauncher
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ activityResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ if (it.resultCode == Activity.RESULT_OK) {
+ if (it.data != null) {
+ val recipient = IntentCompat.getParcelableExtra(
+ it.data!!,
+ Constants.EXTRA_SELECT_RECIPIENT,
+ GetRoomDetailUser::class.java
+ )
+
+ if (recipient != null) {
+ binding.tvRecipientNickname.text = recipient.nickname
+ viewModel.recipientId = recipient.id
+ }
+ }
+ }
+ }
+
+ bindData()
+ }
+
+ @SuppressLint("SetTextI18n")
+ override fun setupView() {
+ loadingDialog = LoadingDialog(this, layoutInflater)
+
+ val replySenderNickname = intent.getStringExtra(Constants.EXTRA_NICKNAME)
+ val replySenderId = intent.getLongExtra(Constants.EXTRA_USER_ID, 0)
+
+ if (replySenderId > 0 && replySenderNickname != null) {
+ binding.ivSelectRecipient.visibility = View.GONE
+ binding.tvRecipientNickname.text = replySenderNickname
+ viewModel.recipientId = replySenderId
+
+ binding.tvTitle.text = "메시지 보내기"
+ }
+
+ binding.tvCancel.setOnClickListener { finish() }
+
+ binding.tvSend.setOnClickListener {
+ viewModel.write {
+ Toast.makeText(
+ applicationContext,
+ "메시지 전송이 완료되었습니다.",
+ Toast.LENGTH_LONG
+ ).show()
+
+ setResult(RESULT_OK)
+ finish()
+ }
+ }
+
+ binding.ivSelectRecipient.setOnClickListener {
+ val intent = Intent(applicationContext, SelectMessageRecipientActivity::class.java)
+ activityResultLauncher.launch(intent)
+ }
+ }
+
+ private fun bindData() {
+ compositeDisposable.add(
+ binding.etMessage.textChanges().skip(1)
+ .debounce(500, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribeOn(Schedulers.io())
+ .subscribe {
+ viewModel.textMessage = it.toString()
+ }
+ )
+
+ viewModel.toastLiveData.observe(this) {
+ it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
+ }
+
+ viewModel.isLoading.observe(this) {
+ if (it) {
+ loadingDialog.show(screenWidth)
+ } else {
+ loadingDialog.dismiss()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteViewModel.kt
new file mode 100644
index 0000000..6fa796a
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteViewModel.kt
@@ -0,0 +1,68 @@
+package kr.co.vividnext.sodalive.message.text
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.orhanobut.logger.Logger
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.base.BaseViewModel
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.message.MessageRepository
+import kr.co.vividnext.sodalive.message.SendTextMessageRequest
+
+class TextMessageWriteViewModel(private val repository: MessageRepository) : BaseViewModel() {
+ var textMessage = ""
+ var recipientId: Long = 0
+
+ private val _toastLiveData = MutableLiveData()
+ val toastLiveData: LiveData
+ get() = _toastLiveData
+
+ private var _isLoading = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
+ fun write(onSuccess: () -> Unit) {
+ if (recipientId <= 0) {
+ _toastLiveData.postValue("받는 사람을 선택해 주세요.")
+ return
+ }
+
+ if (textMessage.isBlank()) {
+ _toastLiveData.postValue("메시지를 입력하세요.")
+ return
+ }
+
+ val request = SendTextMessageRequest(recipientId = recipientId, textMessage = textMessage)
+ _isLoading.value = true
+ compositeDisposable.add(
+ repository.sendTextMessage(
+ request = request,
+ token = "Bearer ${SharedPreferenceManager.token}"
+ )
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ _isLoading.value = false
+ if (it.success) {
+ onSuccess()
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
+ )
+ }
+ }
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ _isLoading.postValue(false)
+ }
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageAdapter.kt
new file mode 100644
index 0000000..1dca904
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageAdapter.kt
@@ -0,0 +1,154 @@
+package kr.co.vividnext.sodalive.message.voice
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.SeekBar
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import coil.transform.RoundedCornersTransformation
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.databinding.ItemVoiceMessageBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+import kr.co.vividnext.sodalive.message.GetVoiceMessageResponse
+import kr.co.vividnext.sodalive.message.MessageBox
+
+class VoiceMessageAdapter(
+ private val onPlay: (GetVoiceMessageResponse.VoiceMessageItem) -> Int,
+ private val onStop: () -> Unit,
+ private val onStartSeekbar: (SeekBar, TextView) -> Unit,
+ private val onItemDelete: (Long) -> Unit,
+ private val onItemKeep: (Long, Boolean) -> Unit,
+ private val onItemReply: (Long, String, String) -> Unit
+) : RecyclerView.Adapter() {
+
+ private var openPlayerItemPosition = -1
+ private var isPlaying = false
+
+ private var messageBox = MessageBox.RECEIVE
+
+ fun setMessageBox(messageBox: MessageBox) {
+ this.messageBox = messageBox
+ }
+
+ inner class ViewHolder(
+ private val binding: ItemVoiceMessageBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+ @SuppressLint("NotifyDataSetChanged")
+ fun bind(item: GetVoiceMessageResponse.VoiceMessageItem, position: Int) {
+ if (openPlayerItemPosition == position) {
+ binding.llPlayer.visibility = View.VISIBLE
+ if (isPlaying) {
+ onStartSeekbar(binding.seekbar, binding.tvTotalDuration)
+ }
+ } else {
+ binding.llPlayer.visibility = View.GONE
+ }
+
+ if (isPlaying) {
+ binding.ivPlayOrStop.setImageResource(R.drawable.btn_bar_stop)
+ } else {
+ binding.ivPlayOrStop.setImageResource(R.drawable.btn_bar_play)
+ }
+
+ if (SharedPreferenceManager.nickname == item.recipientNickname) {
+ binding.ivProfile.load(item.senderProfileImageUrl) {
+ crossfade(true)
+ placeholder(R.drawable.bg_placeholder)
+ transformations(RoundedCornersTransformation(23.4f.dpToPx()))
+ }
+
+ binding.tvNickname.text = item.senderNickname
+ } else {
+ binding.ivProfile.load(item.recipientProfileImageUrl) {
+ crossfade(true)
+ placeholder(R.drawable.bg_placeholder)
+ transformations(RoundedCornersTransformation(23.4f.dpToPx()))
+ }
+
+ binding.tvNickname.text = item.recipientNickname
+ }
+
+ binding.tvDate.text = item.date
+ binding.root.setOnClickListener {
+ if (isPlaying) {
+ onStop()
+ isPlaying = false
+ }
+
+ openPlayerItemPosition = if (
+ openPlayerItemPosition == position &&
+ binding.llPlayer.visibility == View.VISIBLE
+ ) {
+ -1
+ } else {
+ position
+ }
+ notifyDataSetChanged()
+ }
+
+ binding.llPlayer.setOnClickListener {}
+ binding.seekbar.isEnabled = false
+
+ binding.ivPlayOrStop.setOnClickListener {
+ if (isPlaying) {
+ isPlaying = false
+ onStop()
+ binding.ivPlayOrStop.setImageResource(R.drawable.btn_bar_play)
+ } else {
+ val totalDuration = onPlay(item).toLong()
+ if (totalDuration > 0) {
+ isPlaying = true
+ onStartSeekbar(binding.seekbar, binding.tvTotalDuration)
+ binding.ivPlayOrStop.setImageResource(R.drawable.btn_bar_stop)
+ }
+ }
+ }
+
+ if (messageBox == MessageBox.RECEIVE) {
+ binding.ivKeep.visibility = View.VISIBLE
+ binding.ivReply.visibility = View.VISIBLE
+ binding.ivDelete.visibility = View.GONE
+
+ binding.ivKeep.setOnClickListener { onItemKeep(item.messageId, item.isKept) }
+ binding.ivReply.setOnClickListener {
+ onItemReply(
+ item.senderId,
+ item.senderNickname,
+ item.senderProfileImageUrl
+ )
+ }
+ } else {
+ binding.ivKeep.visibility = View.GONE
+ binding.ivReply.visibility = View.GONE
+ binding.ivDelete.visibility = View.VISIBLE
+
+ binding.ivDelete.setOnClickListener { onItemDelete(item.messageId) }
+ }
+ }
+ }
+
+ val items = mutableListOf()
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
+ ItemVoiceMessageBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(items[position], position)
+ }
+
+ override fun getItemCount() = items.size
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun voicePlayComplete() {
+ isPlaying = false
+ notifyDataSetChanged()
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageFragment.kt
new file mode 100644
index 0000000..7e74197
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageFragment.kt
@@ -0,0 +1,355 @@
+package kr.co.vividnext.sodalive.message.voice
+
+import android.annotation.SuppressLint
+import android.graphics.Rect
+import android.media.MediaPlayer
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import android.widget.SeekBar
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.orhanobut.logger.Logger
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.base.BaseFragment
+import kr.co.vividnext.sodalive.common.LoadingDialog
+import kr.co.vividnext.sodalive.databinding.FragmentVoiceMessageBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+import kr.co.vividnext.sodalive.message.MessageBox
+import org.koin.android.ext.android.inject
+import java.util.Timer
+import java.util.TimerTask
+import java.util.concurrent.TimeUnit
+
+class VoiceMessageFragment : BaseFragment(
+ FragmentVoiceMessageBinding::inflate
+) {
+ private val viewModel: VoiceMessageViewModel by inject()
+
+ private lateinit var adapter: VoiceMessageAdapter
+
+ private lateinit var loadingDialog: LoadingDialog
+
+ private var mediaPlayer: MediaPlayer? = null
+
+ private val handler = Handler(Looper.getMainLooper())
+ private var timerTask: TimerTask? = null
+ private var timer: Timer? = null
+
+ override fun onDestroy() {
+ if (mediaPlayer != null) {
+ mediaPlayer!!.release()
+ mediaPlayer = null
+ }
+
+ super.onDestroy()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupView()
+ bindData()
+
+ viewModel.getMessages()
+ }
+
+ private fun setupView() {
+ loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
+
+ binding.tvSent.setOnClickListener {
+ adapter.setMessageBox(MessageBox.SENT)
+ viewModel.selectMessageBox(
+ MessageBox.SENT
+ )
+ }
+
+ binding.tvReceive.setOnClickListener {
+ adapter.setMessageBox(MessageBox.RECEIVE)
+ viewModel.selectMessageBox(
+ MessageBox.RECEIVE
+ )
+ }
+
+ binding.tvKeep.setOnClickListener {
+ adapter.setMessageBox(MessageBox.KEEP)
+ viewModel.selectMessageBox(
+ MessageBox.KEEP
+ )
+ }
+
+ binding.ivWrite.setOnClickListener {
+ stopVoiceMessage()
+ val voiceWriteFragment = VoiceMessageWriteFragment(
+ onSendSuccess = {
+ viewModel.page = 1
+ viewModel.getMessages()
+ }
+ )
+ voiceWriteFragment.show(childFragmentManager, voiceWriteFragment.tag)
+ }
+
+ val recyclerView = binding.rvMessage
+ adapter = VoiceMessageAdapter(
+ onPlay = { playVoiceMessage(it.voiceMessageUrl) },
+ onStop = { stopVoiceMessage() },
+ onStartSeekbar = { seekbar, textView ->
+ startSeekbar(seekbar, textView)
+ },
+ onItemDelete = {
+ viewModel.deleteMessage(it) {
+ Toast.makeText(
+ requireContext(),
+ "메시지가 삭제되었습니다.",
+ Toast.LENGTH_LONG
+ ).show()
+
+ viewModel.page = 1
+ viewModel.getMessages()
+ }
+ },
+ onItemKeep = { messageId, isKept ->
+ if (isKept) {
+ Toast.makeText(
+ requireContext(),
+ "이미 보관된 메시지 입니다.",
+ Toast.LENGTH_LONG
+ ).show()
+ return@VoiceMessageAdapter
+ }
+
+ viewModel.keepVoiceMessage(messageId)
+ },
+ onItemReply = { senderId, senderNickname, senderProfileUrl ->
+ stopVoiceMessage()
+ val voiceWriteFragment = VoiceMessageWriteFragment(
+ onSendSuccess = {
+ viewModel.page = 1
+ viewModel.getMessages()
+ },
+ senderId = senderId,
+ senderNickname = senderNickname,
+ senderProfileUrl = senderProfileUrl
+ )
+ voiceWriteFragment.show(childFragmentManager, voiceWriteFragment.tag)
+ }
+ )
+
+ recyclerView.layoutManager = LinearLayoutManager(
+ requireContext(),
+ LinearLayoutManager.VERTICAL,
+ false
+ )
+
+ recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+
+ outRect.left = 13.3f.dpToPx().toInt()
+ outRect.right = 13.3f.dpToPx().toInt()
+ outRect.top = 13.3f.dpToPx().toInt()
+ outRect.bottom = 13.3f.dpToPx().toInt()
+ }
+ })
+
+ recyclerView.adapter = adapter
+
+ recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+
+ val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!!
+ .findLastCompletelyVisibleItemPosition()
+ val itemTotalCount = recyclerView.adapter!!.itemCount - 1
+
+ // 스크롤이 끝에 도달했는지 확인
+ if (!recyclerView.canScrollVertically(1) &&
+ lastVisibleItemPosition == itemTotalCount
+ ) {
+ viewModel.getMessages(viewModel.messageBoxLiveData.value!!)
+ }
+ }
+ })
+ }
+
+ private fun startSeekbar(seekbar: SeekBar, textView: TextView) {
+ if (timer != null) {
+ timer!!.cancel()
+ timerTask = null
+ timer = null
+ }
+
+ if (mediaPlayer != null) {
+ val duration = mediaPlayer?.duration!!
+ seekbar.max = duration
+ var seconds = TimeUnit.MILLISECONDS.toSeconds(duration.toLong())
+ val minutes = seconds / 60
+ seconds %= 60
+ textView.text = String.format("%02d:%02d", minutes, seconds)
+
+ timerTask = object : TimerTask() {
+ override fun run() {
+ handler.post {
+ seekbar.progress = mediaPlayer?.currentPosition ?: 0
+ Logger.e("test")
+ }
+ }
+ }
+
+ timer = Timer()
+ timer!!.scheduleAtFixedRate(timerTask, 0, 100)
+ }
+ }
+
+ private fun playVoiceMessage(voiceMessageUrl: String): Int {
+ if (mediaPlayer == null) {
+ mediaPlayer = MediaPlayer()
+ mediaPlayer!!.reset()
+
+ mediaPlayer!!.setOnCompletionListener {
+ stopVoiceMessage()
+ }
+
+ mediaPlayer!!.setOnPreparedListener {
+ it.start()
+ }
+
+ try {
+ mediaPlayer!!.setDataSource(voiceMessageUrl)
+ mediaPlayer!!.prepare()
+ } catch (e: Exception) {
+ Toast.makeText(requireActivity(), R.string.retry, Toast.LENGTH_LONG).show()
+ return 0
+ }
+
+ return mediaPlayer!!.duration
+ }
+
+ return 0
+ }
+
+ private fun stopVoiceMessage() {
+ if (timer != null) {
+ timer!!.cancel()
+ timerTask = null
+ timer = null
+ }
+
+ if (mediaPlayer != null) {
+ adapter.voicePlayComplete()
+ mediaPlayer!!.release()
+ mediaPlayer = null
+ }
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ private fun bindData() {
+ viewModel.toastLiveData.observe(viewLifecycleOwner) {
+ it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
+ }
+
+ viewModel.isLoading.observe(viewLifecycleOwner) {
+ if (it) {
+ loadingDialog.show(screenWidth, "음성 메시지를 불러오고 있습니다.")
+ } else {
+ loadingDialog.dismiss()
+ }
+ }
+
+ viewModel.getMessagesLiveData.observe(viewLifecycleOwner) {
+ if (viewModel.page - 1 == 1) {
+ adapter.items.clear()
+ }
+
+ if (adapter.items.size == 0 && it.isEmpty()) {
+ binding.rvMessage.visibility = View.GONE
+ binding.llNoItems.visibility = View.VISIBLE
+ } else {
+ binding.rvMessage.visibility = View.VISIBLE
+ binding.llNoItems.visibility = View.GONE
+ adapter.items.addAll(it)
+ adapter.notifyDataSetChanged()
+ }
+ }
+
+ viewModel.messageBoxLiveData.observe(viewLifecycleOwner) {
+ binding.tvSent.setBackgroundResource(R.drawable.bg_round_corner_16_7_transparent_777777)
+ binding.tvSent.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_777777
+ )
+ )
+
+ binding.tvReceive.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_777777
+ )
+ binding.tvReceive.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_777777
+ )
+ )
+
+ binding.tvKeep.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_777777
+ )
+ binding.tvKeep.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_777777
+ )
+ )
+
+ when (it) {
+ MessageBox.SENT -> {
+ binding.tvSent.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_9970ff
+ )
+ binding.tvSent.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_9970ff
+ )
+ )
+ }
+
+ MessageBox.RECEIVE -> {
+ binding.tvReceive.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_9970ff
+ )
+ binding.tvReceive.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_9970ff
+ )
+ )
+ }
+
+ MessageBox.KEEP -> {
+ binding.tvKeep.setBackgroundResource(
+ R.drawable.bg_round_corner_16_7_transparent_9970ff
+ )
+ binding.tvKeep.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_9970ff
+ )
+ )
+ }
+
+ else -> {
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageViewModel.kt
new file mode 100644
index 0000000..cfe9cae
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageViewModel.kt
@@ -0,0 +1,178 @@
+package kr.co.vividnext.sodalive.message.voice
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.orhanobut.logger.Logger
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.base.BaseViewModel
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.message.GetVoiceMessageResponse
+import kr.co.vividnext.sodalive.message.MessageBox
+import kr.co.vividnext.sodalive.message.MessageRepository
+
+class VoiceMessageViewModel(private val repository: MessageRepository) : BaseViewModel() {
+ private val _messageBoxLiveData = MutableLiveData(MessageBox.RECEIVE)
+ val messageBoxLiveData: LiveData
+ get() = _messageBoxLiveData
+
+ private val _getMessagesLiveData =
+ MutableLiveData>()
+ val getMessagesLiveData: LiveData>
+ get() = _getMessagesLiveData
+
+ private val _toastLiveData = MutableLiveData()
+ val toastLiveData: LiveData
+ get() = _toastLiveData
+
+ var page = 1
+ var pageSize = 10
+ private var totalCount = 0
+
+ private var _isLoading = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
+ fun selectMessageBox(messageBox: MessageBox) {
+ if (messageBox != _messageBoxLiveData.value!!) {
+ page = 1
+ _messageBoxLiveData.postValue(messageBox)
+ getMessages(messageBox)
+ }
+ }
+
+ fun getMessages(messageBox: MessageBox = _messageBoxLiveData.value!!) {
+ if (!_isLoading.value!! && (page - 1 == 0 || totalCount > page * pageSize)) {
+ _isLoading.postValue(true)
+ val messageBoxObservable = when (messageBox) {
+ MessageBox.SENT -> {
+ repository.getSentVoiceMessage(
+ page - 1,
+ pageSize,
+ "Bearer ${SharedPreferenceManager.token}"
+ )
+ }
+
+ MessageBox.RECEIVE -> {
+ repository.getReceivedVoiceMessage(
+ page - 1,
+ pageSize,
+ "Bearer ${SharedPreferenceManager.token}"
+ )
+ }
+
+ MessageBox.KEEP -> {
+ repository.getKeepVoiceMessage(
+ page - 1,
+ pageSize,
+ "Bearer ${SharedPreferenceManager.token}"
+ )
+ }
+ }
+
+ compositeDisposable.add(
+ messageBoxObservable
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ if (it.success && it.data != null) {
+ totalCount = it.data.totalCount
+ _getMessagesLiveData.postValue(it.data.items)
+
+ page += 1
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
+ )
+ }
+ }
+ _isLoading.postValue(false)
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ _isLoading.postValue(false)
+ }
+ )
+ )
+ }
+ }
+
+ fun deleteMessage(messageId: Long, onSuccess: () -> Unit) {
+ if (messageId <= 0) {
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ return
+ }
+
+ compositeDisposable.add(
+ repository.deleteMessage(
+ messageId = messageId,
+ token = "Bearer ${SharedPreferenceManager.token}"
+ )
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ if (it.success) {
+ onSuccess()
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
+ )
+ }
+ }
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ }
+ )
+ )
+ }
+
+ fun keepVoiceMessage(messageId: Long) {
+ if (messageId <= 0) {
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ return
+ }
+
+ _isLoading.value = true
+ compositeDisposable.add(
+ repository.keepVoiceMessage(
+ messageId = messageId,
+ token = "Bearer ${SharedPreferenceManager.token}"
+ ).subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ _isLoading.value = false
+ if (it.success) {
+ _toastLiveData.postValue(
+ "보관되었습니다."
+ )
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
+ )
+ }
+ }
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ _isLoading.value = false
+ }
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteFragment.kt
new file mode 100644
index 0000000..6e37a80
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteFragment.kt
@@ -0,0 +1,431 @@
+package kr.co.vividnext.sodalive.message.voice
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.Dialog
+import android.content.Intent
+import android.media.MediaPlayer
+import android.media.MediaRecorder
+import android.os.Build
+import android.os.Bundle
+import android.os.CountDownTimer
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.core.content.IntentCompat
+import androidx.core.content.res.ResourcesCompat
+import coil.load
+import coil.transform.RoundedCornersTransformation
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.gun0912.tedpermission.PermissionListener
+import com.gun0912.tedpermission.normal.TedPermission
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.common.Constants
+import kr.co.vividnext.sodalive.common.LoadingDialog
+import kr.co.vividnext.sodalive.databinding.FragmentVoiceMessageWriteBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
+import kr.co.vividnext.sodalive.message.SelectMessageRecipientActivity
+import org.koin.android.ext.android.inject
+import java.io.File
+import java.io.IOException
+
+class VoiceMessageWriteFragment(
+ private val onSendSuccess: () -> Unit,
+ private val senderId: Long? = null,
+ private val senderNickname: String? = null,
+ private val senderProfileUrl: String? = null
+) : BottomSheetDialogFragment() {
+
+ private val viewModel: VoiceMessageWriteViewModel by inject()
+
+ private lateinit var loadingDialog: LoadingDialog
+ private lateinit var binding: FragmentVoiceMessageWriteBinding
+ private lateinit var activityResultLauncher: ActivityResultLauncher
+
+ private var countDownTimer: CountDownTimer? = null
+ private var mediaRecorder: MediaRecorder? = null
+ private var mediaPlayer: MediaPlayer? = null
+ private var fileNameMedia = ""
+
+ private var second = -1
+ private var minute = 0
+ private var hour = 0
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ activityResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ if (it.resultCode == Activity.RESULT_OK) {
+ if (it.data != null) {
+ val recipient = IntentCompat.getParcelableExtra(
+ it.data!!,
+ Constants.EXTRA_SELECT_RECIPIENT,
+ GetRoomDetailUser::class.java
+ )
+
+ if (recipient != null) {
+ setReceiver(
+ userId = recipient.id,
+ nickname = recipient.nickname,
+ profileUrl = recipient.profileImageUrl
+ )
+ }
+ }
+ }
+ }
+
+ TedPermission.create()
+ .setPermissionListener(object : PermissionListener {
+ override fun onPermissionGranted() {
+ }
+
+ override fun onPermissionDenied(deniedPermissions: MutableList?) {
+ dismiss()
+ }
+ })
+ .setDeniedMessage("오디오 녹음 권한을 거부하시면 음성 속닥을 이용하실 수 없습니다.")
+ .setPermissions(Manifest.permission.RECORD_AUDIO)
+ .check()
+ }
+
+ private fun setReceiver(userId: Long, nickname: String, profileUrl: String) {
+ binding.ivProfile.load(profileUrl) {
+ crossfade(true)
+ placeholder(R.drawable.bg_placeholder)
+ transformations(RoundedCornersTransformation(23.4f.dpToPx()))
+ }
+ binding.tvNickname.text = nickname
+ binding.tvNickname.typeface = ResourcesCompat.getFont(
+ requireContext(),
+ R.font.gmarket_sans_bold
+ )
+ binding.tvNickname.setTextColor(
+ ContextCompat.getColor(
+ requireContext(),
+ R.color.color_eeeeee
+ )
+ )
+ binding.ivPlus.visibility = View.GONE
+ viewModel.recipientId = userId
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val dialog = super.onCreateDialog(savedInstanceState)
+
+ dialog.setOnShowListener {
+ val d = it as BottomSheetDialog
+ val bottomSheet = d.findViewById(
+ com.google.android.material.R.id.design_bottom_sheet
+ )
+ if (bottomSheet != null) {
+ BottomSheetBehavior.from(bottomSheet).state = BottomSheetBehavior.STATE_EXPANDED
+ }
+ }
+
+ return dialog
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentVoiceMessageWriteBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
+
+ bindData()
+
+ binding.ivClose.setOnClickListener { dismiss() }
+
+ if (senderId != null && senderProfileUrl != null && senderNickname != null) {
+ setReceiver(
+ userId = senderId,
+ nickname = senderNickname,
+ profileUrl = senderProfileUrl
+ )
+ } else {
+ binding.rlSelectRecipient.setOnClickListener {
+ val intent = Intent(requireContext(), SelectMessageRecipientActivity::class.java)
+ activityResultLauncher.launch(intent)
+ }
+ }
+
+ binding.ivRecordStart.setOnClickListener {
+ if (viewModel.recipientId <= 0) {
+ Toast.makeText(
+ requireContext(),
+ "받는 사람을 선택해 주세요.",
+ Toast.LENGTH_LONG
+ ).show()
+
+ return@setOnClickListener
+ }
+
+ fileNameMedia =
+ "${requireActivity().filesDir.path}/socdoc_${System.currentTimeMillis()}.mp3"
+
+ val fileMedia = File(fileNameMedia)
+ if (!fileMedia.exists()) {
+ try {
+ fileMedia.createNewFile()
+
+ startRecording()
+ } catch (e: IOException) {
+ Toast.makeText(requireActivity(), R.string.retry, Toast.LENGTH_LONG).show()
+ e.printStackTrace()
+ }
+ }
+ }
+
+ binding.ivRecordStop.setOnClickListener {
+ stopRecording()
+ }
+
+ binding.ivRecordPlay.setOnClickListener {
+ startPlaying()
+ }
+
+ binding.ivRecordPause.setOnClickListener {
+ stopPlaying()
+ }
+
+ binding.tvDelete.setOnClickListener {
+ if (fileNameMedia.isNotBlank()) {
+ val fileMedia = File(fileNameMedia)
+ if (fileMedia.exists()) {
+ fileMedia.delete()
+ }
+ fileNameMedia = ""
+ }
+
+ binding.ivRecordStart.visibility = View.VISIBLE
+ binding.llRetryOrSend.visibility = View.GONE
+ binding.rlRecordPlay.visibility = View.GONE
+ binding.soundVisualizer.visibility = View.GONE
+ }
+
+ binding.tvRetryRecord.setOnClickListener {
+ if (fileNameMedia.isNotBlank()) {
+ val fileMedia = File(fileNameMedia)
+ if (fileMedia.exists()) {
+ fileMedia.delete()
+ }
+ fileNameMedia = ""
+ }
+
+ binding.ivRecordStart.visibility = View.VISIBLE
+ binding.llRetryOrSend.visibility = View.GONE
+ binding.rlRecordPlay.visibility = View.GONE
+ binding.soundVisualizer.visibility = View.GONE
+ }
+
+ binding.tvSendMessage.setOnClickListener {
+ viewModel.write(File(fileNameMedia)) {
+ dismiss()
+ onSendSuccess()
+ }
+ }
+ }
+
+ private fun bindData() {
+ viewModel.toastLiveData.observe(viewLifecycleOwner) {
+ it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
+ }
+
+ viewModel.isLoading.observe(this) {
+ if (it) {
+ loadingDialog.show(resources.displayMetrics.widthPixels)
+ } else {
+ loadingDialog.dismiss()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ if (mediaPlayer != null) {
+ mediaPlayer!!.release()
+ mediaPlayer = null
+ }
+
+ if (mediaRecorder != null) {
+ // stop recording and free up resources
+ mediaRecorder!!.stop()
+ mediaRecorder!!.reset()
+ mediaRecorder!!.release()
+
+ mediaRecorder = null
+ }
+
+ if (fileNameMedia.isNotBlank()) {
+ val fileMedia = File(fileNameMedia)
+ if (fileMedia.exists()) {
+ fileMedia.delete()
+ }
+
+ fileNameMedia = ""
+ }
+
+ super.onDestroy()
+ }
+
+ private fun startRecording() {
+ if (mediaRecorder == null) {
+ // safety check, don't start a new recording if one is already going
+ mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(requireContext())
+ } else {
+ MediaRecorder()
+ }
+ mediaRecorder!!.setAudioSource(MediaRecorder.AudioSource.MIC)
+ mediaRecorder!!.setOutputFile(fileNameMedia)
+ mediaRecorder!!.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ mediaRecorder!!.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+
+ try {
+ mediaRecorder!!.prepare()
+ } catch (e: Exception) {
+ Toast.makeText(requireActivity(), R.string.retry, Toast.LENGTH_LONG).show()
+ return
+ }
+
+ mediaRecorder!!.start()
+ binding.ivRecordStart.visibility = View.GONE
+ binding.ivRecordStop.visibility = View.VISIBLE
+
+ startCountDownTimer()
+ }
+ }
+
+ private fun stopRecording() {
+ if (mediaRecorder != null) {
+ // stop recording and free up resources
+ mediaRecorder!!.stop()
+ mediaRecorder!!.reset()
+ mediaRecorder!!.release()
+
+ mediaRecorder = null
+
+ binding.ivRecordStop.visibility = View.GONE
+ binding.rlRecordPlay.visibility = View.VISIBLE
+ binding.llRetryOrSend.visibility = View.VISIBLE
+
+ stopCountDownTimer()
+ }
+ }
+
+ private fun startPlaying() {
+ if (mediaPlayer == null) {
+ mediaPlayer = MediaPlayer()
+ mediaPlayer!!.reset()
+
+ mediaPlayer!!.setOnCompletionListener {
+ if (mediaPlayer != null) {
+ mediaPlayer!!.release()
+ mediaPlayer = null
+ }
+ binding.tvDelete.visibility = View.VISIBLE
+ binding.ivRecordPlay.visibility = View.VISIBLE
+ binding.llRetryOrSend.visibility = View.VISIBLE
+ binding.ivRecordPause.visibility = View.GONE
+ binding.soundVisualizer.visibility = View.GONE
+
+ stopCountDownTimer()
+ }
+
+ mediaPlayer!!.setOnPreparedListener {
+ binding.soundVisualizer.visibility = View.VISIBLE
+ binding.soundVisualizer.setAudioSessionId(mediaPlayer!!.audioSessionId)
+ it.start()
+
+ startCountDownTimer()
+ }
+
+ try {
+ mediaPlayer!!.setDataSource(fileNameMedia)
+ mediaPlayer!!.prepare()
+ } catch (e: Exception) {
+ Toast.makeText(requireActivity(), R.string.retry, Toast.LENGTH_LONG).show()
+ return
+ }
+
+ binding.tvDelete.visibility = View.GONE
+ binding.ivRecordPlay.visibility = View.GONE
+ binding.llRetryOrSend.visibility = View.GONE
+ binding.ivRecordPause.visibility = View.VISIBLE
+ }
+ }
+
+ private fun stopPlaying() {
+ if (mediaPlayer != null) {
+ mediaPlayer!!.release()
+ mediaPlayer = null
+ }
+
+ binding.tvDelete.visibility = View.VISIBLE
+ binding.ivRecordPlay.visibility = View.VISIBLE
+ binding.llRetryOrSend.visibility = View.VISIBLE
+ binding.ivRecordPause.visibility = View.GONE
+ binding.soundVisualizer.visibility = View.GONE
+
+ stopCountDownTimer()
+ }
+
+ private fun startCountDownTimer() {
+ countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
+ override fun onTick(p0: Long) {
+ second += 1
+ binding.tvTimer.text = recordingTime()
+ }
+
+ override fun onFinish() {
+ }
+ }
+
+ countDownTimer!!.start()
+ }
+
+ private fun recordingTime(): String {
+ if (second == 60) {
+ minute += 1
+ second = 0
+ }
+
+ if (minute == 60) {
+ hour += 1
+ minute = 0
+ }
+
+ return String.format("%02d:%02d:%02d", hour, minute, second)
+ }
+
+ @SuppressLint("SetTextI18n")
+ private fun stopCountDownTimer() {
+ if (countDownTimer != null) {
+ countDownTimer!!.cancel()
+ countDownTimer = null
+ }
+
+ binding.tvTimer.text = "00:00:00"
+ second = -1
+ minute = 0
+ hour = 0
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteViewModel.kt
new file mode 100644
index 0000000..47a2346
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteViewModel.kt
@@ -0,0 +1,77 @@
+package kr.co.vividnext.sodalive.message.voice
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.google.gson.Gson
+import com.orhanobut.logger.Logger
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.base.BaseViewModel
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.message.MessageRepository
+import kr.co.vividnext.sodalive.message.SendVoiceMessageRequest
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
+
+class VoiceMessageWriteViewModel(private val repository: MessageRepository) : BaseViewModel() {
+ var recipientId: Long = 0
+
+ private val _toastLiveData = MutableLiveData()
+ val toastLiveData: LiveData
+ get() = _toastLiveData
+
+ private var _isLoading = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
+ fun write(file: File, onSuccess: () -> Unit) {
+ if (recipientId <= 0) {
+ _toastLiveData.postValue("받는 사람을 선택해 주세요.")
+ return
+ }
+
+ val request = SendVoiceMessageRequest(recipientId)
+ val requestJson = Gson().toJson(request)
+
+ val recordedFile = MultipartBody.Part.createFormData(
+ "voiceMessageFile",
+ file.name,
+ file.asRequestBody("audio/mpeg".toMediaType())
+ )
+ _isLoading.value = true
+ compositeDisposable.add(
+ repository.sendVoiceMessage(
+ recordedFile,
+ requestJson.toRequestBody("text/plain".toMediaType()),
+ "Bearer ${SharedPreferenceManager.token}"
+ )
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ _isLoading.value = false
+ if (it.success) {
+ _toastLiveData.postValue("메시지 전송이 완료되었습니다.")
+ onSuccess()
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
+ )
+ }
+ }
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
+ _isLoading.postValue(false)
+ }
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt
index b451080..d3350ff 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.user
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.explorer.profile.MemberBlockRequest
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.main.PushTokenUpdateRequest
import kr.co.vividnext.sodalive.mypage.MyPageResponse
import kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponse
@@ -81,4 +82,10 @@ interface UserApi {
request: Any,
@Header("Authorization") authHeader: String
): Single>
+
+ @GET("/member/search")
+ fun searchUser(
+ @Query("nickname") nickname: String,
+ @Header("Authorization") authHeader: String
+ ): Single>>
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt
index ead11a8..f6e7cea 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.user
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.explorer.profile.MemberBlockRequest
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.main.PushTokenUpdateRequest
import kr.co.vividnext.sodalive.mypage.MyPageResponse
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
@@ -62,4 +63,11 @@ class UserRepository(private val userApi: UserApi) {
request = CreatorFollowRequestRequest(creatorId = creatorId),
authHeader = token
)
+
+ fun searchUser(
+ nickname: String,
+ token: String
+ ): Single>> {
+ return userApi.searchUser(nickname, authHeader = token)
+ }
}
diff --git a/app/src/main/res/drawable-xxhdpi/btn_bar_play.png b/app/src/main/res/drawable-xxhdpi/btn_bar_play.png
new file mode 100644
index 0000000..1e1a712
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/btn_bar_play.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/btn_bar_stop.png b/app/src/main/res/drawable-xxhdpi/btn_bar_stop.png
new file mode 100644
index 0000000..d1b72cc
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/btn_bar_stop.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/btn_plus_round.png b/app/src/main/res/drawable-xxhdpi/btn_plus_round.png
new file mode 100644
index 0000000..d66ac27
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/btn_plus_round.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_make_message.png b/app/src/main/res/drawable-xxhdpi/ic_make_message.png
new file mode 100644
index 0000000..5209612
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_make_message.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_make_voice.png b/app/src/main/res/drawable-xxhdpi/ic_make_voice.png
new file mode 100644
index 0000000..bec10a3
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_make_voice.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_mic_paint.png b/app/src/main/res/drawable-xxhdpi/ic_mic_paint.png
new file mode 100644
index 0000000..29c1fb9
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mic_paint.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_record.png b/app/src/main/res/drawable-xxhdpi/ic_record.png
new file mode 100644
index 0000000..3da4ab7
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_record.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_record_pause.png b/app/src/main/res/drawable-xxhdpi/ic_record_pause.png
new file mode 100644
index 0000000..4327dfb
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_record_pause.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_record_play.png b/app/src/main/res/drawable-xxhdpi/ic_record_play.png
new file mode 100644
index 0000000..aa0af8c
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_record_play.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_record_stop.png b/app/src/main/res/drawable-xxhdpi/ic_record_stop.png
new file mode 100644
index 0000000..d44e5ef
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_record_stop.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_save.png b/app/src/main/res/drawable-xxhdpi/ic_save.png
new file mode 100644
index 0000000..70c82f3
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_save.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/img_thumb_default.png b/app/src/main/res/drawable-xxhdpi/img_thumb_default.png
new file mode 100644
index 0000000..784143a
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/img_thumb_default.png differ
diff --git a/app/src/main/res/drawable/bg_round_corner_10_1b1b1b.xml b/app/src/main/res/drawable/bg_round_corner_10_1b1b1b.xml
new file mode 100644
index 0000000..f81c201
--- /dev/null
+++ b/app/src/main/res/drawable/bg_round_corner_10_1b1b1b.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_round_corner_16_7_transparent_777777.xml b/app/src/main/res/drawable/bg_round_corner_16_7_transparent_777777.xml
new file mode 100644
index 0000000..91a031c
--- /dev/null
+++ b/app/src/main/res/drawable/bg_round_corner_16_7_transparent_777777.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_round_corner_6_7_339970ff.xml b/app/src/main/res/drawable/bg_round_corner_6_7_339970ff.xml
new file mode 100644
index 0000000..249cb62
--- /dev/null
+++ b/app/src/main/res/drawable/bg_round_corner_6_7_339970ff.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 0000000..282594c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/voice_message_player_seekbar.xml b/app/src/main/res/drawable/voice_message_player_seekbar.xml
new file mode 100644
index 0000000..1bebd9b
--- /dev/null
+++ b/app/src/main/res/drawable/voice_message_player_seekbar.xml
@@ -0,0 +1,25 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_select_message_recipient.xml b/app/src/main/res/layout/activity_select_message_recipient.xml
new file mode 100644
index 0000000..21a3f12
--- /dev/null
+++ b/app/src/main/res/layout/activity_select_message_recipient.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_text_message_detail.xml b/app/src/main/res/layout/activity_text_message_detail.xml
new file mode 100644
index 0000000..29011e6
--- /dev/null
+++ b/app/src/main/res/layout/activity_text_message_detail.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_text_message_write.xml b/app/src/main/res/layout/activity_text_message_write.xml
new file mode 100644
index 0000000..5335f94
--- /dev/null
+++ b/app/src/main/res/layout/activity_text_message_write.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_message.xml b/app/src/main/res/layout/fragment_message.xml
index 53b2014..026bb37 100644
--- a/app/src/main/res/layout/fragment_message.xml
+++ b/app/src/main/res/layout/fragment_message.xml
@@ -1,16 +1,40 @@
-
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+ android:textColor="@color/color_eeeeee"
+ android:textSize="18.3sp" />
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_text_message.xml b/app/src/main/res/layout/fragment_text_message.xml
new file mode 100644
index 0000000..87ec494
--- /dev/null
+++ b/app/src/main/res/layout/fragment_text_message.xml
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_voice_message.xml b/app/src/main/res/layout/fragment_voice_message.xml
new file mode 100644
index 0000000..6724dcc
--- /dev/null
+++ b/app/src/main/res/layout/fragment_voice_message.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_voice_message_write.xml b/app/src/main/res/layout/fragment_voice_message_write.xml
new file mode 100644
index 0000000..8fb3f19
--- /dev/null
+++ b/app/src/main/res/layout/fragment_voice_message_write.xml
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_text_message.xml b/app/src/main/res/layout/item_text_message.xml
new file mode 100644
index 0000000..ad97949
--- /dev/null
+++ b/app/src/main/res/layout/item_text_message.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_voice_message.xml b/app/src/main/res/layout/item_voice_message.xml
new file mode 100644
index 0000000..a35c2e8
--- /dev/null
+++ b/app/src/main/res/layout/item_voice_message.xml
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index d3c3992..1d8a4a0 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -68,4 +68,5 @@
#FFB600
#99000000
#4C9970FF
+ #4DD8D8D8
diff --git a/settings.gradle b/settings.gradle
index 1d64f28..ecb2f64 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -9,6 +9,7 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
+ jcenter()
mavenCentral()
maven { url 'https://jitpack.io' }
}