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' } }