From 3ef78b64ad18fb7a6c8b4975d2b83298422d1f26 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 2 Aug 2023 14:57:16 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 3 + .../co/vividnext/sodalive/common/Constants.kt | 4 + .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 14 + .../sodalive/explorer/ExplorerFragment.kt | 6 +- .../kr/co/vividnext/sodalive/live/LiveApi.kt | 6 + .../vividnext/sodalive/live/LiveRepository.kt | 2 + .../sodalive/message/GetMessageResponse.kt | 40 ++ .../vividnext/sodalive/message/MessageApi.kt | 100 ++++ .../vividnext/sodalive/message/MessageBox.kt | 14 + .../sodalive/message/MessageFragment.kt | 58 +++ .../sodalive/message/MessageRepository.kt | 124 +++++ .../message/SelectMessageRecipientActivity.kt | 91 ++++ ...er.kt => SelectMessageRecipientAdapter.kt} | 4 +- .../SelectMessageRecipientViewModel.kt | 65 +++ .../sodalive/message/SendMessageRequest.kt | 14 + .../message/text/TextMessageAdapter.kt | 63 +++ .../message/text/TextMessageDetailActivity.kt | 11 + .../message/text/TextMessageFragment.kt | 229 ++++++++++ .../message/text/TextMessageViewModel.kt | 105 +++++ .../message/text/TextMessageWriteActivity.kt | 117 +++++ .../message/text/TextMessageWriteViewModel.kt | 68 +++ .../message/voice/VoiceMessageAdapter.kt | 154 +++++++ .../message/voice/VoiceMessageFragment.kt | 355 +++++++++++++++ .../message/voice/VoiceMessageViewModel.kt | 178 ++++++++ .../voice/VoiceMessageWriteFragment.kt | 431 ++++++++++++++++++ .../voice/VoiceMessageWriteViewModel.kt | 77 ++++ .../kr/co/vividnext/sodalive/user/UserApi.kt | 7 + .../vividnext/sodalive/user/UserRepository.kt | 8 + .../main/res/drawable-xxhdpi/btn_bar_play.png | Bin 0 -> 513 bytes .../main/res/drawable-xxhdpi/btn_bar_stop.png | Bin 0 -> 294 bytes .../res/drawable-xxhdpi/btn_plus_round.png | Bin 0 -> 1038 bytes .../res/drawable-xxhdpi/ic_make_message.png | Bin 0 -> 571 bytes .../res/drawable-xxhdpi/ic_make_voice.png | Bin 0 -> 742 bytes .../main/res/drawable-xxhdpi/ic_mic_paint.png | Bin 0 -> 694 bytes .../main/res/drawable-xxhdpi/ic_record.png | Bin 0 -> 2257 bytes .../res/drawable-xxhdpi/ic_record_pause.png | Bin 0 -> 1144 bytes .../res/drawable-xxhdpi/ic_record_play.png | Bin 0 -> 1437 bytes .../res/drawable-xxhdpi/ic_record_stop.png | Bin 0 -> 1937 bytes app/src/main/res/drawable-xxhdpi/ic_save.png | Bin 0 -> 443 bytes .../res/drawable-xxhdpi/img_thumb_default.png | Bin 0 -> 1101 bytes .../drawable/bg_round_corner_10_1b1b1b.xml | 8 + ...g_round_corner_16_7_transparent_777777.xml | 8 + .../drawable/bg_round_corner_6_7_339970ff.xml | 8 + app/src/main/res/drawable/ic_delete.xml | 5 + .../drawable/voice_message_player_seekbar.xml | 25 + .../activity_select_message_recipient.xml | 37 ++ .../layout/activity_text_message_detail.xml | 125 +++++ .../layout/activity_text_message_write.xml | 126 +++++ app/src/main/res/layout/fragment_message.xml | 42 +- .../main/res/layout/fragment_text_message.xml | 127 ++++++ .../res/layout/fragment_voice_message.xml | 126 +++++ .../layout/fragment_voice_message_write.xml | 218 +++++++++ app/src/main/res/layout/item_text_message.xml | 65 +++ .../main/res/layout/item_voice_message.xml | 142 ++++++ app/src/main/res/values/colors.xml | 1 + settings.gradle | 1 + 57 files changed, 3401 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/GetMessageResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/MessageApi.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/MessageBox.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/MessageRepository.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientActivity.kt rename app/src/main/java/kr/co/vividnext/sodalive/message/{MessageSelectRecipientAdapter.kt => SelectMessageRecipientAdapter.kt} (93%) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/SelectMessageRecipientViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/SendMessageRequest.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageDetailActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/text/TextMessageWriteViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/message/voice/VoiceMessageWriteViewModel.kt create mode 100644 app/src/main/res/drawable-xxhdpi/btn_bar_play.png create mode 100644 app/src/main/res/drawable-xxhdpi/btn_bar_stop.png create mode 100644 app/src/main/res/drawable-xxhdpi/btn_plus_round.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_make_message.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_make_voice.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_mic_paint.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_record.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_record_pause.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_record_play.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_record_stop.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_save.png create mode 100644 app/src/main/res/drawable-xxhdpi/img_thumb_default.png create mode 100644 app/src/main/res/drawable/bg_round_corner_10_1b1b1b.xml create mode 100644 app/src/main/res/drawable/bg_round_corner_16_7_transparent_777777.xml create mode 100644 app/src/main/res/drawable/bg_round_corner_6_7_339970ff.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/voice_message_player_seekbar.xml create mode 100644 app/src/main/res/layout/activity_select_message_recipient.xml create mode 100644 app/src/main/res/layout/activity_text_message_detail.xml create mode 100644 app/src/main/res/layout/activity_text_message_write.xml create mode 100644 app/src/main/res/layout/fragment_text_message.xml create mode 100644 app/src/main/res/layout/fragment_voice_message.xml create mode 100644 app/src/main/res/layout/fragment_voice_message_write.xml create mode 100644 app/src/main/res/layout/item_text_message.xml create mode 100644 app/src/main/res/layout/item_voice_message.xml 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 0000000000000000000000000000000000000000..1e1a712734d0c041e089a2037e22430ac3ceead8 GIT binary patch literal 513 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#Q3?xr`X+H!~Ea{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-1AIbU-3xr)zkmPr>sKHN1Rp+pfG|FN`UGUZd-u+L!rXsA#atype!&cL z1?wL=@A;;hkz39PG>p#Jmj}UaJ5O2boB3rr4=ma7bt{9 z-K=5_6q*^V^-s{ZEIlngVRr3bcRS%bb1MTygoF;I+sNv~-aOp5LSom(yX?Og`1^h6 znP;i4$G@!n8Q+JG%)2}K?KU)5RDW;%`bbvGRaT(k>2B; z@WecRZ|y2+rsWKClT$sYXRj2I&X6ZSV$_NQ9Iwku}^1bSxl{XcEF~Q*J>gTe~DWM4fx>(z7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d1b72ccbc4512c8b07fcefb6563f2d22f6006d2b GIT binary patch literal 294 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#Q3?xr`X+H!~Ea{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaM20(?ST-3xr)zkmPj+czNj?%g{e31K{+^L;W<4SPwDUogWf>6~q|{oE=W z3xGm1JzX3_A`ZX3q086IAmEaCsf{_|z@rVKzEun?+xU8&oa?{++IzrBU3Su}?|;<8 z-ifdZUR*eLUFe&zuWy&hKF(gRYug$y_ja*IY>-X%kGXMoGAd54({p|^?}6XZ?52FJ zI+comkH!akyQVKLJkfYC!9aqCtr5i fk?ikyvEA2T2`njxgN@xNACE9;e literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d66ac2782d56ce8c8660543972e604bc9414b5f4 GIT binary patch literal 1038 zcmV+p1o8WcP)Px#Hc(7dMVWB_nQ;G_asQcc|A%7Yd0EbkW$Z_AOq6T- zkZALoaQ~Tb|Cw?Bn{xk}a{r=z|DAUKnQ;FvS+#Ni000qmQchDc>vfzqIyC??GqucF zI7kc{+r)*{000A7Nklw3c=2!>UxYdz#}|2I1mlPHOx$yR>#dAhwtkxxMJ z)3zyiucCP$rj9=s4VnihrLz{IS-X4&KRriI%1H};5QPmMtaNI6f*d0cyr^055UJx$ z zibsMQ#7!F1ky05awtFQ!s7{#z}EFspW!{dSxN--t@?k$C zV?rX?ppxh8C7nKtY+!LaGpoquD?=pYDsBLjY?$RgtFmE8*;pm`n&gGJnX0%MTHMx) z;u@bnNN`xMv&v>ZzbMkBdnyh7cAagdfsp@q>23wgwaj{48ti2jTMTY)ZpC1$fH{gm zu4LVIGnK5ZGRq$&D_2XG3!kR1pi+nGYqFMe^ZoW@U!xz?NlR~vpSwoi8 zER~|&N)ZiTp-aiQCMDp8k7!G8wNmG{lvw(WhVR-u$Fv;&(w0_Q{Q4lGyLqk-BI@Ik z&K92-m&6dwPX`gsQllqld;GlmIK%w`89RVr2V~zE<9%b;xSY=$BT>;a61_LLRb7MI zZ;Y4Ij<$#3{>~8m8>9NhgMxVUppd@%=VuT8ab|pueDZ|!3o%@NMK`@xGynhq07*qo IM6N<$g2{Q@Q2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5209612569626c56efcb8739e864420b03a86d05 GIT binary patch literal 571 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r63?ysp-_HY5Ea{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaO81AIbU-3xsF69IS^&Flhd;VB973uai=Ep*;()@%K=-F3p7r@sOkJIm9> zF~sBe+w1oJha4o1e`H>wX(cF}E|mKEfJ0J=@fyBfK0O|z*RQS}ynFQM4vQa56~aPG z_`Wa7c{%_0-MgP{?x;1Nzob_4d7-Y1uGia_cX|%*T6b;n0)vZHq8x1#JtfyM9TYHH z>7?>_v(NR(^41EIJ_gN>RNk>~+X?I5$xIfrl_aC**0B~`a{hFOZ$4uP*U2NV%s8}I zQ`Xn~%!)Ygnbqj=t$VKeE2(J;lVXdRzO@^z{9kxv*!e z{I3V!QvO_EcTYLdP9tkr>aS_@+yt zMOLuHNe+mXI5Vj@bE!BpJT_N!`LJ=XfD1>$N5z_ricLrTBhIy*xwQFkTE|lN_QeM# zL{+7ke{!^v`Jo`YYH9uaC5I0#+I6$%YTtwz|3ADxw4(OGf@cSGS{EzrNk6=^GDSZ99r(fwrYO<=Vi@aj`?TS&K7VjJG}b0 zOEXhe0S`m2`;i%}&9gV%kgZyK;@i_TMpqhQ9c5!v)svQ=Pvud26EUCt+)VFB$DRou z`l(vO8GmT{2YaEQ)&&)Q8VXv^{eOHHy!1$O;g8zV2+f;{iQj_Mx6I_%c$)W6J5oL6 zdiiglmP|EK!QPf{N#4)R$_zc#C7#O|%`IT;n4~=~?e8`7iX}`d*d?ByF88N5 zFX!3YJ-oNi`CZ{usS>=VUT)&F8*9`17DXC@uDX{ruU7 zJL{fKNZzt%tNDauA9hSlL*d%aD2Q>(biGw*-_fi71@B(j$5I^TB4N2++4phiS^F(yY`|-(sgf4_pbq_ECx?kKbLh*2~7Zqpl7rI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..29c1fb94d34d940f8041fed04f8b20772b2af3dc GIT binary patch literal 694 zcmV;n0!jUeP)Px#El^BUMMrQSIK?0006F zNklMPs z2{*|DnMG&LlZIqs*b#hyVxLZ2DR6W;y95k4fXi5whX~&6IZnu?tvoc&Vpr0yiBh-q_;h8FM7kh-X5~GdftzP7z z1nd!3V2^%Sqj?)W=*JB!ShRx=8Rc?#d2CVyR>C+{SVHKQhh{vfvSUyYo{I7evhyxi ztAIoOYN2E)&vHrNpbyAJ^>OqTQx#~OM6+7erxz2=`XFap=6cD$Fwrj#+Uqpkjn-)| z4iiO;8!{o~%uH-Lu)!{!;mQQ`L1f}^Y0A2>N!Tx3Xt!t>N+726^FZ2gb@Jc%te-~I zb2pll>zeP`F$`Bsmmv&kehXkN#6GDx>Y+nkkeZ}T>#8>)onEf$wqa1Z8W^^#k0%0D z%Z}JLKAlP8a*l1Bx1k@nzK{N-F?1;3Mey%IPyR|M*J92REJuN@Sc2|CFlZfnt5#?A czxz+}1*LgUbCk@k@c;k-07*qoM6N<$g6dmE!vFvP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3da4ab7a2d7f445dbaeec457288626c6431bad51 GIT binary patch literal 2257 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d44e5effcc575b3a5b72f258ca47d54f74db8f90 GIT binary patch literal 1937 zcmV;C2X6R@P)Px#MNmvsMMrQ<|KQ&L|NsC0|Nr>+|NsC0|NsB@_W%F? z|NsC0|NsC0|NsC0|NsC0|NsA8N&j3*|6ED`T}uC5OaER<|NsC0TuI}6No4>402Opn zPE!C4u;&HVz#V?|Iz?1!m(PIks8VwHb~x|=00zlPL_t(|+U;H0a;q>5#ry6gZOQ+C z>SbCM(~#6A7=-5Psnf{-$2vNa_ok_3%6c6mm4Y#2+SVAxgrqTQZ&h<9Q(jZiA$V>^ zQ0-3uia0G_0-1r5+7Yl&xYMF7HlZB{=$1fDyV48|pq#}0dMpDX55-3|vM1plb?9VF zDMC0Wr2E7ApyLynV9^3lv4wasGKNWvlUC=Gh=;f!8A2bcc9t%vmCd9Lw+kvrk8_ce ztD(GrBpn1WCa%pP#6@#7AU_Y8H-)K_#(5C=1Hp)}K?JKH4g#dorZn5BkJm<{?TZr<5s1`s4C-lFiEUdqXJKq#)NvV zk$dqvjZaz0L6+^PIti;;mAXgo7oop3M^_}8(fjnZI@LC{2saT;?pVW5ZOB|5qUk+5 zjhyQmgo||=DJz0TTEwl|h#o<-N5aufh(TmMfhl9U+(3hrIE-iygcE8rG~&QfiOoJ@ z6li(qz}`ss_bLQyHVwlKHf41K!1SB(Lk2XV0byalvnq`F z5PrS^Vt$aNHPolDe3SZzuCLQ{1IYB+`b}?K_-$Ma7dEm3l5d{opQ7|LBpeBD+Hw%6 zqVyx*k;KaNPMC$siqsyrZeg0pytozZ0fR-l!3n1jQ24ic#ac+Q!V#P2h0RMoE#oW zVExaGzN!547p0v&_4RSN-0pAK?Q;3}DvekASKF`A0dl?EH?qsMh~z_L{NWs31m(#G(*nS8JEra4y-E+maZ>*VWv^Gw#(bvt@bD ztGJ-w*(&{2AI?{qR?qUKFYFn7)Q9s?$Tt6wbn?4xm-=wBgXiJnd9QZjz zli$&~)rWJ-@AjBqflo`PLGL?Q{@27g+j@BJtZ>|%T&}FZ0SAZ7-Jy%;b6~;gyJ~t} zy#^1?tZpOaGxfmW^c5#rilh_5nQ2*h=s<%r3m#cM2LcUF>Jd}E$ORglpWrc5MOqX( z;NbMZqs9uZY!v}CIDNqQk$3UjaN-#Mbj1=$aB#v{g^F?|fCtBob%7}x!+{A$4brA2 zjXN=%(J8iUND3;PA!PiT$!d6TXs^_3bQ&O>L8;@KRV(n|3|@(AR_4Ql6M8sw&9W7E za0UsGE3zm+IJU1m#fmHn4-Qv-zF{cf^wj{v2FE0WASZ@H`dSScoSz{oes(zg2M0Dd z|6>O-od03k!@1OwuKzo27hG7FJLec$Kpo?FT?sIIG+mh9 zB78MKJmHzC*xBMK2U-!T0ODZ+Q=io?c^M0viyR3oX|NqMC(+Li(6*{gbOOfgl(JLC zb$~YGgGw7}2lX67D2dhYAll%~D$Jj`@A%OiP)py_IqqqDAee7Ja`pu#x|Lx>Tr{&s z$zw+L+#FDAq_%}l;#SK!mR24&OJ?UBDTA~CrDLsajx?o3`v@rp`%)d04e=35D>Q)l zJ{wuPU`EiWgSSd4LO3U+`@{O6BNb-p_bl25hIPWU_=Wjqs0TGMM#6eTh6aF>u-q-g z(Vr%?gS(uGxCOO(>J_Qo2^In>ub+f_A`{xH=1kVfn2wQ3!IuRXo300000NkvXXu0mjf{t&_i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..70c82f3a508e42539ff8a1b86a5e42e70cda0251 GIT binary patch literal 443 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r63?ysp-_HY5Ea{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaNj1AIbU-3xpwz@QukrcImH(9i(n@q2O00hMu-1o;Is1ULS9xP+C*vT=hd z&=@UG7sn8f<8P;J^gC=I;ySmntoO=-G@a-$5AD>o69SGYP3vnobxuEE`K7-(Z%hjB zao0@~XKQ(%@>Ev-_Qqt_l~eAYI{wbID|*U_$33EEoIVLm|0N^dJYP_K=<=&M&5@UX zI^HQRV71?IXo1XuHImim9&s_hu{Q2(N?*C8Re!e6A$dnT` a%*UHIKi{_Q-f3V+GI+ZBxvXPx#Gf+%aMMrQJpc()G z1ENVpK~#9!?VQV&oI^h7Ve1Nw=v9)-jY&G zsbs+zUU|`*V)#@|>=<4p3{z@&)s|#T!!pcwl*2k%!ZZxSh<)d?=dmFQ)TTnFQ*cH9fkK|t*NOYzpE!OTU!WwNyG3=M2a%EGWnIQa)#4?KPh3rE&q2_4NckSiCM6s|UvFf^m9a+<%z2Fr?%vr&zau(?&iZ zfRc9R7^ouU<})E7Pg|giQW&1_SCtr8BGmBlm$($TD(Gznd%Z1e7Ia$d&=>!W4}(oc z0b3GDp+z`HE!bjFZGu6W<|r1eh@i<$EV!Dv!xk9Zyo4(Z1Gee5&8%xEmrRPpFbq<<@N*~Z4Oq=|KI4LdZQ*!KU z7$ALQ&s)!k`QCqUZ5lQWrm_aq#*DIcWBI#h1#wz3E(c*lWe>n<<|%O_qGWU?!|CcQ zDI%2;HWLTk4PfJ&OusIY%9{ptwNt8UG<`fYwcx61PS(}7Jv?kq8`@V*t^T@(Aasp~ z=o*~SHBzK&*h<$JnyvvoU89SAS2%gZRMWVtvO!>7Bhoufm*H{tXuYPf`sa?L=fMG8 zPca;eT+bsF&-72x^BuY#Hc|HEi?YXXv^^8_3}x~IO1 + + + + + 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' } }