diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c90386..f0aa820 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,9 @@ + + + Unit, + cancelButtonTitle: String = "", + cancelButtonClick: (() -> Unit)? = null, +) { + + private val alertDialog: AlertDialog + + val dialogView = DialogLiveBinding.inflate(layoutInflater) + + init { + val dialogBuilder = AlertDialog.Builder(activity) + dialogBuilder.setView(dialogView.root) + + alertDialog = dialogBuilder.create() + alertDialog.setCancelable(false) + alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + dialogView.tvTitle.text = title + dialogView.tvDesc.text = desc + + dialogView.tvCancel.text = cancelButtonTitle + dialogView.tvCancel.setOnClickListener { + alertDialog.dismiss() + cancelButtonClick?.let { it() } + } + + dialogView.tvConfirm.text = confirmButtonTitle + dialogView.tvConfirm.setOnClickListener { + alertDialog.dismiss() + confirmButtonClick() + } + + dialogView.tvCancel.visibility = if (cancelButtonTitle.isNotBlank()) { + View.VISIBLE + } else { + View.GONE + } + + dialogView.tvConfirm.visibility = if (confirmButtonTitle.isNotBlank()) { + View.VISIBLE + } else { + View.GONE + } + } + + fun show(width: Int) { + alertDialog.show() + + val lp = WindowManager.LayoutParams() + lp.copyFrom(alertDialog.window?.attributes) + lp.width = width - (26.7f.dpToPx()).toInt() + lp.height = WindowManager.LayoutParams.WRAP_CONTENT + + alertDialog.window?.attributes = lp + } +} 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 80de8ae..272b69f 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 @@ -3,14 +3,34 @@ package kr.co.vividnext.sodalive.live import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse +import kr.co.vividnext.sodalive.live.room.CancelLiveRequest +import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest import kr.co.vividnext.sodalive.live.room.LiveRoomStatus +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.tag.GetLiveTagResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body 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 LiveApi { + @GET("/live/tag") + fun getTags( + @Header("Authorization") authHeader: String + ): Single>> + @GET("/live/room") fun roomList( @Query("timezone") timezone: String, @@ -27,4 +47,50 @@ interface LiveApi { @Query("timezone") timezone: String, @Header("Authorization") authHeader: String ): Single> + + @GET("/live/room/recent-room-info") + fun getRecentRoomInfo( + @Header("Authorization") authHeader: String + ): Single> + + @POST("/live/room") + @Multipart + fun createRoom( + @Part coverImage: MultipartBody.Part?, + @Part("request") request: RequestBody, + @Header("Authorization") authHeader: String + ): Single> + + @PUT("/live/room/start") + fun startLive( + @Body request: StartLiveRequest, + @Header("Authorization") authHeader: String + ): Single> + + @PUT("/live/room/cancel") + fun cancelLive( + @Body request: CancelLiveRequest, + @Header("Authorization") authHeader: String + ): Single> + + @POST("/live/room/enter") + fun enterRoom( + @Body request: EnterOrQuitLiveRoomRequest, + @Header("Authorization") authHeader: String + ): Single> + + @POST("/live/reservation") + fun makeReservation( + @Body request: MakeLiveReservationRequest, + @Header("Authorization") authHeader: String + ): Single> + + @PUT("/live/room/{id}") + @Multipart + fun editLiveRoomInfo( + @Path("id") id: Long, + @Part coverImage: MultipartBody.Part?, + @Part("request") request: RequestBody?, + @Header("Authorization") authHeader: String + ): Single> } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt index 771aca9..31c8eaa 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt @@ -1,14 +1,19 @@ package kr.co.vividnext.sodalive.live import android.annotation.SuppressLint +import android.app.Activity import android.content.Intent import android.graphics.Rect import android.net.Uri import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.View import android.webkit.URLUtil import android.widget.LinearLayout 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 @@ -17,16 +22,25 @@ import com.zhpan.indicator.enums.IndicatorSlideMode import com.zhpan.indicator.enums.IndicatorStyle 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.common.SharedPreferenceManager import kr.co.vividnext.sodalive.databinding.FragmentLiveBinding import kr.co.vividnext.sodalive.extensions.dpToPx +import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.live.event_banner.EventBannerAdapter import kr.co.vividnext.sodalive.live.now.LiveNowAdapter import kr.co.vividnext.sodalive.live.recommend.RecommendLiveAdapter import kr.co.vividnext.sodalive.live.recommend_channel.LiveRecommendChannelAdapter import kr.co.vividnext.sodalive.live.reservation.LiveReservationAdapter +import kr.co.vividnext.sodalive.live.reservation.complete.LiveReservationCompleteActivity +import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateActivity +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment +import kr.co.vividnext.sodalive.live.room.dialog.LiveCancelDialog +import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog +import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog +import kr.co.vividnext.sodalive.live.room.update.LiveRoomEditActivity import kr.co.vividnext.sodalive.settings.notification.MemberRole import org.koin.android.ext.android.inject import kotlin.math.roundToInt @@ -39,8 +53,23 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl private lateinit var liveRecommendChannelAdapter: LiveRecommendChannelAdapter private lateinit var loadingDialog: LoadingDialog + private lateinit var activityResultLauncher: ActivityResultLauncher private var message = "" + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + activityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + refreshSummary() + } + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -76,7 +105,11 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl } else { View.GONE } - binding.ivMakeRoom.setOnClickListener {} + + binding.ivMakeRoom.setOnClickListener { + val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java) + activityResultLauncher.launch(intent) + } binding.swipeRefreshLayout.setOnRefreshListener { refreshSummary() } @@ -320,10 +353,10 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl val detailFragment = LiveRoomDetailFragment( it.roomId, onClickParticipant = {}, - onClickReservation = {}, - onClickModify = {}, - onClickStart = {}, - onClickCancel = {} + onClickReservation = { reservationRoom(it.roomId) }, + onClickModify = { roomDetailResponse -> modifyLive(roomDetailResponse) }, + onClickStart = { startLive(it.roomId) }, + onClickCancel = { cancelLive(it.roomId) } ) if (detailFragment.isAdded) return@LiveReservationAdapter @@ -431,4 +464,93 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl } } } + + private fun startLive(roomId: Long) { + val onEnterRoomSuccess = { + viewModel.getSummary() + } + + viewModel.startLive(roomId, onEnterRoomSuccess) + } + + private fun cancelLive(roomId: Long) { + LiveCancelDialog( + activity = requireActivity(), + layoutInflater = layoutInflater, + title = "예약취소", + hint = "취소사유를 입력하세요.", + confirmButtonTitle = "예약취소", + confirmButtonClick = { + viewModel.cancelLive(roomId, it) { + Toast.makeText( + requireActivity(), + "예약이 취소되었습니다.", + Toast.LENGTH_LONG + ).show() + message = "라이브를 불러오고 있습니다." + liveNowAdapter.clear() + liveReservationAdapter.clear() + viewModel.getSummary() + } + }, + cancelButtonTitle = "닫기", + cancelButtonClick = {} + ).show(screenWidth) + } + + fun reservationRoom(roomId: Long) { + viewModel.getRoomDetail(roomId) { + if (it.manager.id == SharedPreferenceManager.userId) { + showToast("내가 만든 라이브는 예약할 수 없습니다.") + } else { + if (it.isPrivateRoom) { + LiveRoomPasswordDialog( + activity = requireActivity(), + layoutInflater = layoutInflater, + can = if (it.isPaid) 0 else it.price, + confirmButtonClick = { password -> + handler.postDelayed({ + processLiveReservation(roomId, password) + }, 300) + } + ).show(screenWidth) + } else { + if (it.price == 0 || it.isPaid) { + processLiveReservation(roomId) + } else { + LivePaymentDialog( + activity = requireActivity(), + layoutInflater = layoutInflater, + title = "${it.price.moneyFormat()}코인으로 예약", + desc = "'${it.title}' 라이브에 참여하기 위해 결제합니다.", + confirmButtonTitle = "예약하기", + confirmButtonClick = { processLiveReservation(roomId) }, + cancelButtonTitle = "취소", + cancelButtonClick = {} + ).show(screenWidth) + } + } + } + } + } + + private fun processLiveReservation(roomId: Long, password: String = "") { + viewModel.reservationRoom(roomId, password) { + refreshSummary() + val intent = Intent( + requireActivity(), + LiveReservationCompleteActivity::class.java + ) + intent.putExtra(Constants.EXTRA_LIVE_RESERVATION_RESPONSE, it) + startActivity(intent) + } + } + + private fun modifyLive(roomDetail: GetRoomDetailResponse) { + startActivity( + Intent(requireContext(), LiveRoomEditActivity::class.java).apply { + putExtra(Constants.EXTRA_ROOM_DETAIL, roomDetail) + } + ) + } } 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 2e79523..7167f78 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 @@ -3,8 +3,15 @@ package kr.co.vividnext.sodalive.live import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest +import kr.co.vividnext.sodalive.live.room.CancelLiveRequest +import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest import kr.co.vividnext.sodalive.live.room.LiveRoomStatus +import kr.co.vividnext.sodalive.live.room.StartLiveRequest +import kr.co.vividnext.sodalive.live.room.create.CreateLiveRoomResponse import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody import java.util.TimeZone class LiveRepository(private val api: LiveApi) { @@ -32,4 +39,59 @@ class LiveRepository(private val api: LiveApi) { authHeader = token ) } + + fun getRecentRoomInfo(token: String) = api.getRecentRoomInfo(authHeader = token) + + fun createRoom( + coverImage: MultipartBody.Part? = null, + request: RequestBody, + token: String + ): Single> { + return api.createRoom( + coverImage, + request, + authHeader = token + ) + } + + fun startLive( + request: StartLiveRequest, + token: String + ) = api.startLive( + request, + authHeader = token + ) + + fun cancelLive( + request: CancelLiveRequest, + token: String + ) = api.cancelLive(request, authHeader = token) + + fun enterRoom( + request: EnterOrQuitLiveRoomRequest, + token: String + ) = api.enterRoom( + request, + authHeader = token + ) + + fun makeReservation( + request: MakeLiveReservationRequest, + token: String + ) = api.makeReservation( + request, + authHeader = token + ) + + fun editLiveRoomInfo( + roomId: Long, + coverImage: MultipartBody.Part? = null, + request: RequestBody? = null, + token: String + ) = api.editLiveRoomInfo( + id = roomId, + coverImage = coverImage, + request = request, + authHeader = token + ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveViewModel.kt index bba57cb..51c07d7 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveViewModel.kt @@ -11,7 +11,13 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.live.recommend.GetRecommendLiveResponse import kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepository import kr.co.vividnext.sodalive.live.recommend_channel.GetRecommendChannelResponse +import kr.co.vividnext.sodalive.live.room.CancelLiveRequest +import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest import kr.co.vividnext.sodalive.live.room.LiveRoomStatus +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse +import kr.co.vividnext.sodalive.live.room.StartLiveRequest +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.settings.event.EventRepository @@ -238,4 +244,177 @@ class LiveViewModel( ) } } + + fun startLive(roomId: Long, onEnterRoomSuccess: () -> Unit) { + _isLoading.value = true + compositeDisposable.add( + repository.startLive( + StartLiveRequest(roomId), + "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success) { + enterRoom(roomId, onEnterRoomSuccess) + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + ) + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } + + fun cancelLive(roomId: Long, reason: String, onSuccess: () -> Unit) { + _isLoading.postValue(true) + + compositeDisposable.add( + repository.cancelLive( + CancelLiveRequest(roomId, reason), + "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (it.success) { + _isLoading.value = false + onSuccess() + } else { + _isLoading.value = false + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + ) + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } + + fun enterRoom(roomId: Long, onSuccess: () -> Unit, password: Int? = null) { + _isLoading.value = true + val request = EnterOrQuitLiveRoomRequest(roomId, password = password) + compositeDisposable.add( + repository.enterRoom(request, "Bearer ${SharedPreferenceManager.token}") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (it.success) { + _isLoading.value = false + onSuccess() + } else { + _isLoading.value = false + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + ) + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } + + fun getRoomDetail(roomId: Long, onSuccess: (GetRoomDetailResponse) -> Unit) { + _isLoading.value = true + compositeDisposable.add( + repository.getRoomDetail( + roomId = roomId, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + onSuccess(it.data) + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "라이브 정보를 가져오지 못했습니다.\n다시 시도해 주세요." + ) + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue( + "라이브 정보를 가져오지 못했습니다.\n다시 시도해 주세요." + ) + } + ) + ) + } + + fun reservationRoom( + roomId: Long, + password: String? = null, + onSuccess: (MakeLiveReservationResponse) -> Unit + ) { + _isLoading.value = true + compositeDisposable.add( + repository.makeReservation( + MakeLiveReservationRequest(roomId = roomId, password = password), + "Bearer ${SharedPreferenceManager.token}" + ) + .retry(3) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + onSuccess(it.data) + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + ) + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationRequest.kt new file mode 100644 index 0000000..2fca0a8 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationRequest.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.live.reservation + +import com.google.gson.annotations.SerializedName +import java.util.TimeZone + +data class MakeLiveReservationRequest( + @SerializedName("roomId") val roomId: Long, + @SerializedName("container") val container: String = "aos", + @SerializedName("timezone") val timezone: String = TimeZone.getDefault().id, + @SerializedName("password") val password: String? = null +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationResponse.kt new file mode 100644 index 0000000..9673145 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.live.reservation + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MakeLiveReservationResponse( + @SerializedName("reservationId") val reservationId: Long, + @SerializedName("nickname") val nickname: String, + @SerializedName("title") val title: String, + @SerializedName("beginDateString") val beginDateString: String, + @SerializedName("price") val price: String, + @SerializedName("haveCan") val haveCan: Int, + @SerializedName("useCan") val useCan: Int, + @SerializedName("remainingCoin") val remainingCoin: Int +) : Parcelable diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/complete/LiveReservationCompleteActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/complete/LiveReservationCompleteActivity.kt new file mode 100644 index 0000000..8d21d71 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/complete/LiveReservationCompleteActivity.kt @@ -0,0 +1,53 @@ +package kr.co.vividnext.sodalive.live.reservation.complete + +import android.content.Intent +import android.widget.Toast +import androidx.core.content.IntentCompat +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.base.BaseActivity +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.databinding.ActivityLiveReservationCompleteBinding +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse +import kr.co.vividnext.sodalive.main.MainActivity + +class LiveReservationCompleteActivity : BaseActivity( + ActivityLiveReservationCompleteBinding::inflate +) { + override fun setupView() { + val response = IntentCompat.getParcelableExtra( + intent, + Constants.EXTRA_LIVE_RESERVATION_RESPONSE, + MakeLiveReservationResponse::class.java + ) + + if (response == null) { + Toast.makeText(applicationContext, R.string.retry, Toast.LENGTH_LONG).show() + finish() + return + } + + binding.toolbar.tvBack.text = "라이브 예약 완료" + binding.toolbar.tvBack.setOnClickListener { finish() } + + binding.tvNickname.text = response.nickname + binding.tvTitle.text = response.title + binding.tvDate.text = response.beginDateString + binding.tvPrice.text = response.price + + binding.tvHaveCoin.text = "${response.haveCan}" + binding.tvUseCoin.text = "${response.useCan}" + binding.tvRemainingCoin.text = "${response.remainingCoin}" + + binding.tvGoHome.setOnClickListener { + val intent = Intent(applicationContext, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(intent) + finish() + } + + binding.tvGoReservationList.setOnClickListener { + finish() + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/CancelLiveRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/CancelLiveRequest.kt new file mode 100644 index 0000000..de4dde2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/CancelLiveRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.live.room + +import com.google.gson.annotations.SerializedName + +data class CancelLiveRequest( + @SerializedName("roomId") val roomId: Long, + @SerializedName("reason") val reason: String +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/EnterOrQuitLiveRoomRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/EnterOrQuitLiveRoomRequest.kt new file mode 100644 index 0000000..c78db54 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/EnterOrQuitLiveRoomRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.live.room + +import com.google.gson.annotations.SerializedName + +data class EnterOrQuitLiveRoomRequest( + @SerializedName("roomId") val roomId: Long, + @SerializedName("container") val container: String = "aos", + @SerializedName("password") val password: Int? = null +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomType.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomType.kt new file mode 100644 index 0000000..9460caa --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomType.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.live.room + +import com.google.gson.annotations.SerializedName + +enum class LiveRoomType { + @SerializedName("OPEN") OPEN, + @SerializedName("PRIVATE") PRIVATE, +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/StartLiveRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/StartLiveRequest.kt new file mode 100644 index 0000000..08af7fc --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/StartLiveRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.live.room + +import com.google.gson.annotations.SerializedName +import java.util.TimeZone + +data class StartLiveRequest( + @SerializedName("roomId") val roomId: Long, + @SerializedName("timezone") val timezone: String = TimeZone.getDefault().id, +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/CreateLiveRoomRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/CreateLiveRoomRequest.kt new file mode 100644 index 0000000..1a13005 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/CreateLiveRoomRequest.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.live.room.create + +import com.google.gson.annotations.SerializedName +import kr.co.vividnext.sodalive.live.room.LiveRoomType + +data class CreateLiveRoomRequest( + @SerializedName("title") val title: String, + @SerializedName("price") val price: Int = 0, + @SerializedName("content") val content: String, + @SerializedName("coverImageUrl") val coverImageUrl: String? = null, + @SerializedName("isAdult") val isAdult: Boolean, + @SerializedName("tags") val tags: List, + @SerializedName("numberOfPeople") val numberOfPeople: Int, + @SerializedName("beginDateTimeString") val beginDateTimeString: String? = null, + @SerializedName("timezone") val timezone: String, + @SerializedName("type") val type: LiveRoomType, + @SerializedName("password") val password: String? = null +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/CreateLiveRoomResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/CreateLiveRoomResponse.kt new file mode 100644 index 0000000..d0028a9 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/CreateLiveRoomResponse.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.live.room.create + +import com.google.gson.annotations.SerializedName + +data class CreateLiveRoomResponse( + @SerializedName("id") val id: Long?, + @SerializedName("channelName") val channelName: String? +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/GetRecentRoomInfoResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/GetRecentRoomInfoResponse.kt new file mode 100644 index 0000000..80e4565 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/GetRecentRoomInfoResponse.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.live.room.create + +import com.google.gson.annotations.SerializedName + +data class GetRecentRoomInfoResponse( + @SerializedName("title") val title: String, + @SerializedName("notice") val notice: String, + @SerializedName("coverImageUrl") val coverImageUrl: String, + @SerializedName("coverImagePath") val coverImagePath: String, + @SerializedName("numberOfPeople") val numberOfPeople: Int +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateActivity.kt new file mode 100644 index 0000000..cb80816 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateActivity.kt @@ -0,0 +1,616 @@ +package kr.co.vividnext.sodalive.live.room.create + +import android.annotation.SuppressLint +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import coil.load +import coil.transform.RoundedCornersTransformation +import com.github.dhaval2404.imagepicker.ImagePicker +import com.jakewharton.rxbinding4.widget.textChanges +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.R +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.common.RealPathUtil +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.databinding.ActivityLiveRoomCreateBinding +import kr.co.vividnext.sodalive.databinding.ItemLiveTagSelectedBinding +import kr.co.vividnext.sodalive.extensions.convertDateFormat +import kr.co.vividnext.sodalive.extensions.dpToPx +import kr.co.vividnext.sodalive.live.room.LiveRoomType +import kr.co.vividnext.sodalive.live.room.tag.LiveTagFragment +import kr.co.vividnext.sodalive.settings.notification.MemberRole +import org.koin.android.ext.android.inject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class LiveRoomCreateActivity : BaseActivity( + ActivityLiveRoomCreateBinding::inflate +) { + + private val viewModel: LiveRoomCreateViewModel by inject() + + private lateinit var loadingDialog: LoadingDialog + + private val handler = Handler(Looper.getMainLooper()) + + private val datePickerDialogListener = + DatePickerDialog.OnDateSetListener { _, year, monthOfYear, dayOfMonth -> + viewModel.beginDate = String.format("%d-%02d-%02d", year, monthOfYear + 1, dayOfMonth) + viewModel.setReservationDate( + String.format( + "%d.%02d.%02d", + year, + monthOfYear + 1, + dayOfMonth + ) + ) + } + + private val timePickerDialogListener = + TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute -> + val timeString = String.format("%02d:%02d", hourOfDay, minute) + viewModel.beginTime = timeString + viewModel.setReservationTime(timeString.convertDateFormat("HH:mm", "a hh:mm")) + } + + private val tagFragment: LiveTagFragment by lazy { + LiveTagFragment(viewModel.tags) { tag, isChecked -> + when { + isChecked && viewModel.tags.size < 3 -> { + viewModel.addTag(tag) + return@LiveTagFragment true + } + + !isChecked -> { + viewModel.removeTag(tag) + return@LiveTagFragment true + } + + else -> { + Toast.makeText( + this, + "최대 3개까지 선택 가능합니다.", + Toast.LENGTH_SHORT + ).show() + return@LiveTagFragment false + } + } + } + } + + private val imageResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val resultCode = result.resultCode + val data = result.data + + if (resultCode == RESULT_OK) { + // Image Uri will not be null for RESULT_OK + val fileUri = data?.data!! + binding.ivCover.background = null + binding.ivCover.load(fileUri) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(13.3f.dpToPx())) + } + viewModel.coverImageUri = fileUri + viewModel.coverImagePath = null + } else if (resultCode == ImagePicker.RESULT_ERROR) { + Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewModel.getRealPathFromURI = { + RealPathUtil.getRealPath(applicationContext, it) + } + + bindData() + + viewModel.setTimeNow( + intent.getBooleanExtra(Constants.EXTRA_LIVE_TIME_NOW, true) + ) + } + + @SuppressLint("SetTextI18n", "ClickableViewAccessibility") + override fun setupView() { + loadingDialog = LoadingDialog(this, layoutInflater) + + + binding.tvBack.setOnClickListener { finish() } + binding.ivPhotoPicker.setOnClickListener { + ImagePicker.with(this) + .crop() + .galleryOnly() + .galleryMimeTypes( // Exclude gif images + mimeTypes = arrayOf( + "image/png", + "image/jpg", + "image/jpeg" + ) + ) + .createIntent { imageResult.launch(it) } + } + + binding.llOpen.setOnClickListener { + viewModel.setRoomType(LiveRoomType.OPEN) + binding.llConfigTime.visibility = View.VISIBLE + binding.tvConfigTime.visibility = View.VISIBLE + } + binding.llPrivate.setOnClickListener { + viewModel.setRoomType(LiveRoomType.PRIVATE) + binding.llConfigTime.visibility = View.VISIBLE + binding.tvConfigTime.visibility = View.VISIBLE + } + + binding.llTimeNow.setOnClickListener { viewModel.setTimeNow(true) } + binding.llTimeReservation.setOnClickListener { viewModel.setTimeNow(false) } + + binding.tvReservationDate.setOnClickListener { + val reservationDate = viewModel.beginDate.split("-") + val datePicker: DatePickerDialog + + if (reservationDate.isNotEmpty() && reservationDate.size == 3) { + datePicker = DatePickerDialog( + this, + R.style.DatePickerStyle, + datePickerDialogListener, + reservationDate[0].toInt(), + reservationDate[1].toInt() - 1, + reservationDate[2].toInt() + ) + } else { + val dateString = SimpleDateFormat( + "yyyy.MM.dd", + Locale.getDefault() + ).format(Date()).split(".") + + datePicker = DatePickerDialog( + this, + R.style.DatePickerStyle, + datePickerDialogListener, + dateString[0].toInt(), + dateString[1].toInt() - 1, + dateString[2].toInt() + ) + } + + datePicker.show() + } + + binding.tvReservationTime.setOnClickListener { + val reservationTime = viewModel.beginTime.split(":") + val timePicker: TimePickerDialog + + if (reservationTime.isNotEmpty() && reservationTime.size == 2) { + timePicker = TimePickerDialog( + this, + R.style.TimePickerStyle, + timePickerDialogListener, + reservationTime[0].toInt(), + reservationTime[1].toInt(), + false + ) + } else { + val timeString = SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(Date()).split(":") + + timePicker = TimePickerDialog( + this, + R.style.TimePickerStyle, + timePickerDialogListener, + timeString[0].toInt(), + timeString[1].toInt(), + false + ) + } + + timePicker.show() + } + + binding.tvSelectTag.setOnClickListener { + if (tagFragment.isAdded) return@setOnClickListener + + tagFragment.show(supportFragmentManager, tagFragment.tag) + } + + binding.tvMakeRoom.setOnClickListener { + binding.tvMakeRoom.isEnabled = false + viewModel.createLiveRoom { + val intent = Intent() + if (it.id != null) { + intent.putExtra(Constants.EXTRA_ROOM_ID, it.id) + } + + if (it.channelName != null) { + intent.putExtra(Constants.EXTRA_ROOM_CHANNEL_NAME, it.channelName) + } + setResult(RESULT_OK, intent) + finish() + } + + handler.postDelayed( + { binding.tvMakeRoom.isEnabled = true }, + 3000 + ) + } + + if (SharedPreferenceManager.isAuth) { + binding.llSetAdult.visibility = View.VISIBLE + } else { + binding.llSetAdult.visibility = View.GONE + } + + if (SharedPreferenceManager.role == MemberRole.CREATOR.name) { + binding.llPrice.visibility = View.VISIBLE + binding.tvPriceFree.setOnClickListener { binding.etPrice.setText("0") } + binding.tvPrice100.setOnClickListener { binding.etPrice.setText("100") } + binding.tvPrice300.setOnClickListener { binding.etPrice.setText("300") } + binding.tvPrice500.setOnClickListener { binding.etPrice.setText("500") } + binding.tvPrice1000.setOnClickListener { binding.etPrice.setText("1000") } + binding.tvPrice2000.setOnClickListener { binding.etPrice.setText("2000") } + } else { + binding.llPrice.visibility = View.GONE + } + + binding.tvGetRecentInfo.setOnClickListener { + viewModel.getRecentInfo { + binding.tvGetRecentInfo.visibility = View.GONE + + binding.etTitle.setText(it.title) + binding.etNotice.setText(it.notice) + binding.etNumberOfPeople.setText(it.numberOfPeople.toString()) + binding.ivCover.background = null + binding.ivCover.load(it.coverImageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(13.3f.dpToPx())) + } + } + } + + binding.etNotice.setOnTouchListener { view, motionEvent -> + view.parent.parent.requestDisallowInterceptTouchEvent(true) + if ((motionEvent.action and MotionEvent.ACTION_MASK) == MotionEvent.ACTION_UP) { + view.parent.parent.requestDisallowInterceptTouchEvent(false) + } + false + } + } + + @SuppressLint("SetTextI18n") + private fun bindData() { + compositeDisposable.add( + binding.etTitle.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + viewModel.title = it.toString() + } + ) + + compositeDisposable.add( + binding.etNotice.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + binding.tvNumberOfCharacters.text = "${it.length}자" + viewModel.content = it.toString() + } + ) + + compositeDisposable.add( + binding.etNumberOfPeople.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + if (it.isEmpty()) { + viewModel.numberOfPeople = 0 + } else { + viewModel.numberOfPeople = it.toString().toInt() + } + } + ) + + compositeDisposable.add( + binding.etRoomPassword.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + if (it.isEmpty()) { + viewModel.password = null + } else { + try { + viewModel.password = it.toString() + } catch (e: NumberFormatException) { + binding.etRoomPassword.setText(it.substring(0, it.length - 1)) + } + } + } + ) + + 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() + } + } + + viewModel.timeNowLiveData.observe(this) { + if (it) { + binding.llReservationDatetime.visibility = View.GONE + binding.ivTimeReservation.visibility = View.GONE + binding.ivTimeNow.visibility = View.VISIBLE + binding.llTimeNow.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff) + binding.llTimeReservation.setBackgroundResource( + R.drawable.bg_round_corner_6_7_1f1734 + ) + binding.tvTimeNow.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_eeeeee + ) + ) + + binding.tvTimeReservation.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_9970ff + ) + ) + } else { + binding.llReservationDatetime.visibility = View.VISIBLE + binding.ivTimeReservation.visibility = View.VISIBLE + binding.ivTimeNow.visibility = View.GONE + binding.llTimeNow.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734) + binding.llTimeReservation.setBackgroundResource( + R.drawable.bg_round_corner_6_7_9970ff + ) + + binding.tvTimeNow.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_9970ff + ) + ) + + binding.tvTimeReservation.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_eeeeee + ) + ) + } + } + + viewModel.roomTypeLiveData.observe(this) { + when (it) { + LiveRoomType.PRIVATE -> { + binding.ivPrivate.visibility = View.VISIBLE + binding.ivOpen.visibility = View.GONE + + binding.llPrivate.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff) + binding.llOpen.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734) + + binding.tvPrivate.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_eeeeee + ) + ) + + binding.tvOpen.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_9970ff + ) + ) + + binding.llRoomPassword.visibility = View.VISIBLE + } + + else -> { + binding.ivOpen.visibility = View.VISIBLE + binding.ivPrivate.visibility = View.GONE + + binding.llOpen.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff) + binding.llPrivate.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734) + + binding.tvPrivate.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_9970ff + ) + ) + + binding.tvOpen.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_eeeeee + ) + ) + + binding.llRoomPassword.visibility = View.GONE + } + } + } + + viewModel.reservationDateLiveData.observe(this) { + binding.tvReservationDate.text = it + } + + viewModel.reservationTimeLiveData.observe(this) { + binding.tvReservationTime.text = it + } + + viewModel.selectedLiveData.observe(this) { + binding.llSelectTags.removeAllViews() + for (index in it.indices) { + val tag = it[index] + val itemView = ItemLiveTagSelectedBinding.inflate(layoutInflater) + itemView.tvTag.text = tag + itemView.ivRemove.setOnClickListener { + viewModel.removeTag(tag) + } + binding.llSelectTags.addView(itemView.root) + + if (index > 0) { + val layoutParams = itemView.root.layoutParams as LinearLayout.LayoutParams + layoutParams.marginStart = 10.dpToPx().toInt() + itemView.root.layoutParams = layoutParams + } + } + } + + if (SharedPreferenceManager.role == MemberRole.CREATOR.name || + SharedPreferenceManager.isAuth + ) { + + binding.llAgeAll.setOnClickListener { + viewModel.setAdult(false) + } + + binding.llAge19.setOnClickListener { + viewModel.setAdult(true) + } + + viewModel.isAdultLiveData.observe(this) { + if (it) { + binding.ivAgeAll.visibility = View.GONE + binding.llAgeAll.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734) + binding.tvAgeAll.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_9970ff + ) + ) + + binding.ivAge19.visibility = View.VISIBLE + binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff) + binding.tvAge19.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_eeeeee + ) + ) + } else { + binding.ivAge19.visibility = View.GONE + binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734) + binding.tvAge19.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_9970ff + ) + ) + + binding.ivAgeAll.visibility = View.VISIBLE + binding.llAgeAll.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff) + binding.tvAgeAll.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_eeeeee + ) + ) + } + } + } + + if (SharedPreferenceManager.role == MemberRole.CREATOR.name) { + compositeDisposable.add( + binding.etPrice.textChanges() + .skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + if (it.isNotEmpty()) { + val price = it.toString().toUIntOrNull() + if (price != null) { + viewModel.setPrice(price.toInt()) + } else { + binding.etPrice.setText(it.substring(0, it.length - 1)) + binding.etPrice.setSelection(it.length - 1) + } + } else { + viewModel.setPrice(0) + } + } + ) + + viewModel.priceLiveData.observe(this) { + allPriceSelectFalse() + + when (it) { + 0 -> priceSelect(binding.tvPriceFree) + 100 -> priceSelect(binding.tvPrice100) + 300 -> priceSelect(binding.tvPrice300) + 500 -> priceSelect(binding.tvPrice500) + 1000 -> priceSelect(binding.tvPrice1000) + 2000 -> priceSelect(binding.tvPrice2000) + else -> binding.rlPrice.isSelected = true + } + } + } + } + + private fun allPriceSelectFalse() { + binding.rlPrice.isSelected = false + priceSelectFalse(binding.tvPriceFree) + priceSelectFalse(binding.tvPrice100) + priceSelectFalse(binding.tvPrice300) + priceSelectFalse(binding.tvPrice500) + priceSelectFalse(binding.tvPrice1000) + priceSelectFalse(binding.tvPrice2000) + } + + private fun priceSelectFalse(priceView: TextView) { + priceView.isSelected = false + priceView.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_eeeeee + ) + ) + priceView.typeface = ResourcesCompat.getFont( + applicationContext, + R.font.gmarket_sans_medium + ) + } + + private fun priceSelect(priceView: TextView) { + priceView.isSelected = true + priceView.setTextColor( + ContextCompat.getColor( + applicationContext, + R.color.color_9970ff + ) + ) + priceView.typeface = ResourcesCompat.getFont( + applicationContext, + R.font.gmarket_sans_bold + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateViewModel.kt new file mode 100644 index 0000000..2c29da7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateViewModel.kt @@ -0,0 +1,255 @@ +package kr.co.vividnext.sodalive.live.room.create + +import android.net.Uri +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.live.LiveRepository +import kr.co.vividnext.sodalive.live.room.LiveRoomType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.util.TimeZone + +class LiveRoomCreateViewModel( + private val repository: LiveRepository +) : BaseViewModel() { + + private val _roomTypeLiveData = MutableLiveData(LiveRoomType.OPEN) + val roomTypeLiveData: LiveData + get() = _roomTypeLiveData + + private val _timeNowLiveData = MutableLiveData(true) + val timeNowLiveData: LiveData + get() = _timeNowLiveData + + private val _reservationDateLiveData = MutableLiveData("날짜를 선택해주세요") + val reservationDateLiveData: LiveData + get() = _reservationDateLiveData + + private val _reservationTimeLiveData = MutableLiveData("시간을 설정해주세요") + val reservationTimeLiveData: LiveData + get() = _reservationTimeLiveData + + private val _selectedLiveData = MutableLiveData>() + val selectedLiveData: LiveData> + get() = _selectedLiveData + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private var _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _priceLiveData = MutableLiveData(0) + val priceLiveData: LiveData + get() = _priceLiveData + + private val _isAdultLiveData = MutableLiveData(false) + val isAdultLiveData: LiveData + get() = _isAdultLiveData + + lateinit var getRealPathFromURI: (Uri) -> String? + + var title = "" + var content = "" + var numberOfPeople = 0 + var tags = mutableSetOf() + var beginDate = "" + var beginTime = "" + var coverImageUri: Uri? = null + var coverImagePath: String? = null + var password: String? = null + + fun setRoomType(roomType: LiveRoomType) { + if (_roomTypeLiveData.value!! != roomType) { + _roomTypeLiveData.postValue(roomType) + } + } + + fun setTimeNow(timeNow: Boolean) { + _timeNowLiveData.postValue(timeNow) + } + + fun setReservationDate(dateString: String) { + _reservationDateLiveData.postValue(dateString) + } + + fun setReservationTime(timeString: String) { + _reservationTimeLiveData.postValue(timeString) + } + + fun createLiveRoom(onSuccess: (CreateLiveRoomResponse) -> Unit) { + if (!_isLoading.value!! && validateData()) { + _isLoading.postValue(true) + val request = CreateLiveRoomRequest( + title = title, + price = _priceLiveData.value!!, + content = content, + coverImageUrl = coverImagePath, + isAdult = _isAdultLiveData.value!!, + tags = tags.toList(), + numberOfPeople = numberOfPeople, + beginDateTimeString = if ( + !_timeNowLiveData.value!! + ) { + "$beginDate $beginTime" + } else { + null + }, + timezone = TimeZone.getDefault().id, + type = _roomTypeLiveData.value!!, + password = if ( + _roomTypeLiveData.value!! == LiveRoomType.PRIVATE && + password != null + ) { + password + } else { + null + } + ) + + val requestJson = Gson().toJson(request) + + val coverImage = if (coverImageUri != null) { + val file = File(getRealPathFromURI(coverImageUri!!)) + MultipartBody.Part.createFormData( + "coverImage", + file.name, + file.asRequestBody("image/*".toMediaType()) + ) + } else { + null + } + compositeDisposable.add( + repository.createRoom( + coverImage, + requestJson.toRequestBody("text/plain".toMediaType()), + "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (it.success && it.data != null) { + onSuccess(it.data!!) + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + ) + } + } + _isLoading.postValue(false) + }, + { + _isLoading.postValue(false) + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } + } + + fun removeTag(tag: String) { + tags.remove(tag) + _selectedLiveData.postValue(tags.toList()) + } + + fun addTag(tag: String) { + tags.add(tag) + _selectedLiveData.postValue(tags.toList()) + } + + private fun validateData(): Boolean { + if (title.isBlank()) { + _toastLiveData.postValue("제목을 입력해주세요.") + return false + } + + if (content.isBlank() || content.length < 5) { + _toastLiveData.postValue("내용을 5자 이상 입력해주세요.") + return false + } + + if (numberOfPeople < 3 || numberOfPeople > 999) { + _toastLiveData.postValue("인원을 3~999명 사이로 입력해주세요.") + return false + } + + if (coverImageUri == null && coverImagePath == null) { + _toastLiveData.postValue("커버이미지를 선택해주세요.") + return false + } + + if (!_timeNowLiveData.value!! && (beginDate.isBlank() || beginTime.isBlank())) { + _toastLiveData.postValue("예약날짜와 시간을 선택해주세요.") + return false + } + + if ( + _roomTypeLiveData.value!! == LiveRoomType.PRIVATE && + (password == null || password!!.length != 6) + ) { + _toastLiveData.postValue("방 입장 비밀번호 6자리를 입력해 주세요.") + return false + } + + return true + } + + fun setPrice(price: Int) { + _priceLiveData.value = price + } + + fun setAdult(isAdult: Boolean) { + _isAdultLiveData.value = isAdult + } + + fun getRecentInfo(onSuccess: (GetRecentRoomInfoResponse) -> Unit) { + _isLoading.value = true + compositeDisposable.add( + repository.getRecentRoomInfo(token = "Bearer ${SharedPreferenceManager.token}") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (it.success && it.data != null) { + coverImageUri = null + coverImagePath = it.data.coverImagePath + onSuccess(it.data!!) + + _toastLiveData.postValue("최근데이터를 불러왔습니다.") + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "최근데이터를 불러오지 못했습니다.\n다시 시도해 주세요." + ) + } + } + + _isLoading.value = false + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("최근데이터를 불러오지 못했습니다.\n다시 시도해 주세요.") + } + ) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt index 37ff507..4161011 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt @@ -9,7 +9,7 @@ data class GetRoomDetailResponse( @SerializedName("roomId") val roomId: Long, @SerializedName("price") val price: Int, @SerializedName("title") val title: String, - @SerializedName("content") val content: String, + @SerializedName("notice") val notice: String, @SerializedName("isPaid") val isPaid: Boolean, @SerializedName("isPrivateRoom") val isPrivateRoom: Boolean, @SerializedName("password") val password: Int?, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt index 6c8c2bf..ba18d11 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt @@ -173,7 +173,7 @@ class LiveRoomDetailFragment( setParticipantUserSummary(response.participatingUsers) binding.tvTags.text = response.tags.joinToString(" ") { "#$it" } - binding.tvContent.text = response.content + binding.tvContent.text = response.notice if (response.channelName.isNullOrBlank()) { binding.tvParticipateExpression.text = "예약자" diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LiveCancelDialog.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LiveCancelDialog.kt new file mode 100644 index 0000000..c37a350 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LiveCancelDialog.kt @@ -0,0 +1,77 @@ +package kr.co.vividnext.sodalive.live.room.dialog + +import android.app.Activity +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import kr.co.vividnext.sodalive.databinding.DialogLiveInputBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class LiveCancelDialog( + activity: Activity, + layoutInflater: LayoutInflater, + title: String, + hint: String, + confirmButtonTitle: String, + confirmButtonClick: (String) -> Unit, + cancelButtonTitle: String = "", + cancelButtonClick: (() -> Unit)? = null, +) { + private val alertDialog: AlertDialog + private val dialogView = DialogLiveInputBinding.inflate(layoutInflater) + + init { + val dialogBuilder = AlertDialog.Builder(activity) + dialogBuilder.setView(dialogView.root) + + alertDialog = dialogBuilder.create() + alertDialog.setCancelable(false) + alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + dialogView.tvTitle.text = title + dialogView.etReason.hint = hint + + dialogView.tvCancel.text = cancelButtonTitle + dialogView.tvCancel.setOnClickListener { + alertDialog.dismiss() + cancelButtonClick?.let { it() } + } + + dialogView.tvConfirm.text = confirmButtonTitle + dialogView.tvConfirm.setOnClickListener { + if (dialogView.etReason.text.isNotBlank()) { + alertDialog.dismiss() + confirmButtonClick(dialogView.etReason.text.toString()) + } else { + Toast.makeText(activity, "취소사유를 입력하세요.", Toast.LENGTH_LONG).show() + } + } + + dialogView.tvCancel.visibility = if (cancelButtonTitle.isNotBlank()) { + View.VISIBLE + } else { + View.GONE + } + + dialogView.tvConfirm.visibility = if (confirmButtonTitle.isNotBlank()) { + View.VISIBLE + } else { + View.GONE + } + } + + fun show(width: Int) { + alertDialog.show() + + val lp = WindowManager.LayoutParams() + lp.copyFrom(alertDialog.window?.attributes) + lp.width = width - (26.7f.dpToPx()).toInt() + lp.height = WindowManager.LayoutParams.WRAP_CONTENT + + alertDialog.window?.attributes = lp + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LivePaymentDialog.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LivePaymentDialog.kt new file mode 100644 index 0000000..c7ca33b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LivePaymentDialog.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.live.room.dialog + +import android.app.Activity +import android.view.LayoutInflater +import android.widget.LinearLayout +import kr.co.vividnext.sodalive.dialog.LiveDialog + +class LivePaymentDialog( + activity: Activity, + layoutInflater: LayoutInflater, + title: String, + desc: String, + confirmButtonTitle: String, + confirmButtonClick: () -> Unit, + cancelButtonTitle: String = "", + cancelButtonClick: (() -> Unit)? = null, +) : LiveDialog( + activity, + layoutInflater, + title, + desc, + confirmButtonTitle, + confirmButtonClick, + cancelButtonTitle, + cancelButtonClick +) { + init { + val lp = dialogView.tvConfirm.layoutParams as LinearLayout.LayoutParams + lp.weight = 2F + dialogView.tvConfirm.layoutParams = lp + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LiveRoomPasswordDialog.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LiveRoomPasswordDialog.kt new file mode 100644 index 0000000..e3166ca --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/dialog/LiveRoomPasswordDialog.kt @@ -0,0 +1,84 @@ +package kr.co.vividnext.sodalive.live.room.dialog + +import android.app.Activity +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import com.jakewharton.rxbinding4.widget.textChanges +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.databinding.DialogLiveRoomPasswordBinding +import kr.co.vividnext.sodalive.extensions.dpToPx +import kr.co.vividnext.sodalive.extensions.moneyFormat + +class LiveRoomPasswordDialog( + activity: Activity, + layoutInflater: LayoutInflater, + can: Int, + confirmButtonClick: (String) -> Unit, +) { + + private val alertDialog: AlertDialog + val dialogView = DialogLiveRoomPasswordBinding.inflate(layoutInflater) + + private val compositeDisposable = CompositeDisposable() + + init { + val dialogBuilder = AlertDialog.Builder(activity) + dialogBuilder.setView(dialogView.root) + + alertDialog = dialogBuilder.create() + alertDialog.setCancelable(false) + alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + if (can > 0) { + dialogView.tvCoin.visibility = View.VISIBLE + dialogView.tvCoin.text = can.moneyFormat() + dialogView.tvConfirm.text = "으로 입장" + } else { + dialogView.tvCoin.visibility = View.GONE + dialogView.tvConfirm.text = "입장하기" + } + + compositeDisposable.add( + dialogView.etPassword.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + if (it.isNotEmpty()) { + try { + it.toString().toInt() + } catch (e: NumberFormatException) { + dialogView.etPassword.setText(it.substring(0, it.length - 1)) + } + } + } + ) + dialogView.tvCancel.setOnClickListener { alertDialog.dismiss() } + dialogView.llConfirm.setOnClickListener { + alertDialog.dismiss() + if (dialogView.etPassword.text.isNotBlank()) { + confirmButtonClick(dialogView.etPassword.text.toString()) + } else { + confirmButtonClick("") + } + } + + alertDialog.setOnDismissListener { compositeDisposable.clear() } + } + + fun show(width: Int) { + alertDialog.show() + + val lp = WindowManager.LayoutParams() + lp.copyFrom(alertDialog.window?.attributes) + lp.width = width - (26.7f.dpToPx()).toInt() + lp.height = WindowManager.LayoutParams.WRAP_CONTENT + + alertDialog.window?.attributes = lp + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/GetLiveTagResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/GetLiveTagResponse.kt new file mode 100644 index 0000000..6cc7187 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/GetLiveTagResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.live.room.tag + +import com.google.gson.annotations.SerializedName + +data class GetLiveTagResponse( + @SerializedName("id") val id: Long, + @SerializedName("tag") val tag: String, + @SerializedName("image") val image: String +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagAdapter.kt new file mode 100644 index 0000000..504be6d --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagAdapter.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.live.room.tag + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import coil.load +import coil.transform.RoundedCornersTransformation +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemLiveTagBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class LiveTagAdapter( + private val selectedTags: Set, + private val onItemClick: (String, Boolean) -> Boolean +) : RecyclerView.Adapter() { + inner class ViewHolder( + private val context: Context, + private val binding: ItemLiveTagBinding + ) : RecyclerView.ViewHolder(binding.root) { + + private var isChecked = false + + fun bind(item: GetLiveTagResponse) { + if (selectedTags.contains(item.tag)) { + binding.ivTagChecked.visibility = View.VISIBLE + binding.tvTag.setTextColor(ContextCompat.getColor(context, R.color.color_9970ff)) + isChecked = true + } else { + binding.ivTagChecked.visibility = View.GONE + binding.tvTag.setTextColor(ContextCompat.getColor(context, R.color.color_bbbbbb)) + isChecked = false + } + + binding.ivTag.load(item.image) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(30f.dpToPx())) + } + binding.tvTag.text = item.tag + + binding.root.setOnClickListener { + isChecked = !isChecked + + if (onItemClick(item.tag, isChecked)) { + if (isChecked) { + binding.ivTagChecked.visibility = View.VISIBLE + binding.tvTag.setTextColor( + ContextCompat.getColor( + context, + R.color.color_9970ff + ) + ) + } else { + binding.ivTagChecked.visibility = View.GONE + binding.tvTag.setTextColor( + ContextCompat.getColor( + context, + R.color.color_bbbbbb + ) + ) + } + } else { + isChecked = !isChecked + } + } + } + } + + val items = mutableSetOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + parent.context, + ItemLiveTagBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items.toList()[position]) + } + + override fun getItemCount() = items.size +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagFragment.kt new file mode 100644 index 0000000..3714dde --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagFragment.kt @@ -0,0 +1,107 @@ +package kr.co.vividnext.sodalive.live.room.tag + +import android.annotation.SuppressLint +import android.app.Dialog +import android.graphics.Rect +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.extensions.dpToPx +import org.koin.android.ext.android.inject + +class LiveTagFragment( + private val selectedTags: Set, + private val onItemClick: (String, Boolean) -> Boolean +) : BottomSheetDialogFragment() { + + + private val viewModel: LiveTagViewModel by inject() + + private lateinit var adapter: LiveTagAdapter + + 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 = inflater.inflate(R.layout.fragment_live_tag, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.iv_close).setOnClickListener { + dialog?.dismiss() + } + + view.findViewById(R.id.tv_select).setOnClickListener { + dialog?.dismiss() + } + + setupAdapter(view) + bindData() + + viewModel.getTags() + } + + private fun setupAdapter(view: View) { + val recyclerView = view.findViewById(R.id.rv_tags) + adapter = LiveTagAdapter(selectedTags) { tag, isChecked -> + return@LiveTagAdapter onItemClick(tag, isChecked) + } + + recyclerView.setHasFixedSize(true) + recyclerView.layoutManager = GridLayoutManager(requireContext(), 4) + recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + outRect.top = 13.3f.dpToPx().toInt() + outRect.bottom = 13.3f.dpToPx().toInt() + outRect.left = 13.3f.dpToPx().toInt() + outRect.right = 13.3f.dpToPx().toInt() + } + }) + recyclerView.adapter = adapter + } + + @SuppressLint("NotifyDataSetChanged") + private fun bindData() { + viewModel.toastLiveData.observe(viewLifecycleOwner) { + it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() } + } + + viewModel.tagLiveData.observe(viewLifecycleOwner) { + adapter.items.addAll(it) + adapter.notifyDataSetChanged() + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagRepository.kt new file mode 100644 index 0000000..721b15c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagRepository.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.live.room.tag + +import io.reactivex.rxjava3.core.Single +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.live.LiveApi + +class LiveTagRepository(private val api: LiveApi) { + fun getTags(token: String): Single>> { + return api.getTags(token) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagViewModel.kt new file mode 100644 index 0000000..691b7fe --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/tag/LiveTagViewModel.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.live.room.tag + +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 + +class LiveTagViewModel(private val repository: LiveTagRepository) : BaseViewModel() { + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private val _tagLiveData = MutableLiveData>() + val tagLiveData: LiveData> + get() = _tagLiveData + + fun getTags() { + compositeDisposable.add( + repository.getTags("Bearer ${SharedPreferenceManager.token}") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (it.success && it.data != null) { + _tagLiveData.postValue(it.data!!) + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + ) + } + } + }, + { + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/EditLiveRoomInfoRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/EditLiveRoomInfoRequest.kt new file mode 100644 index 0000000..00f6568 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/EditLiveRoomInfoRequest.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.live.room.update + +import com.google.gson.annotations.SerializedName + +data class EditLiveRoomInfoRequest( + @SerializedName("title") val title: String?, + @SerializedName("content") val notice: String?, + @SerializedName("numberOfPeople") val numberOfPeople: Int?, + @SerializedName("beginDateTimeString") val beginDateTimeString: String?, + @SerializedName("timezone") val timezone: String? +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/LiveRoomEditActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/LiveRoomEditActivity.kt new file mode 100644 index 0000000..39f003f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/LiveRoomEditActivity.kt @@ -0,0 +1,221 @@ +package kr.co.vividnext.sodalive.live.room.update + +import android.annotation.SuppressLint +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import android.widget.Toast +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.R +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.ActivityLiveRoomEditBinding +import kr.co.vividnext.sodalive.extensions.convertDateFormat +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse +import org.koin.android.ext.android.inject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class LiveRoomEditActivity : BaseActivity( + ActivityLiveRoomEditBinding::inflate +) { + + private val viewModel: LiveRoomEditViewModel by inject() + + private lateinit var loadingDialog: LoadingDialog + + private val handler = Handler(Looper.getMainLooper()) + + private val datePickerDialogListener = + DatePickerDialog.OnDateSetListener { _, year, monthOfYear, dayOfMonth -> + viewModel.beginDate = String.format("%d-%02d-%02d", year, monthOfYear + 1, dayOfMonth) + viewModel.setReservationDate( + String.format( + "%d.%02d.%02d", + year, + monthOfYear + 1, + dayOfMonth + ) + ) + } + + private val timePickerDialogListener = + TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute -> + val timeString = String.format("%02d:%02d", hourOfDay, minute) + viewModel.beginTime = timeString + viewModel.setReservationTime(timeString.convertDateFormat("HH:mm", "a hh:mm")) + } + + override fun onCreate(savedInstanceState: Bundle?) { + val roomDetail = IntentCompat.getParcelableExtra( + intent, + Constants.EXTRA_ROOM_DETAIL, + GetRoomDetailResponse::class.java + ) + + super.onCreate(savedInstanceState) + + if (roomDetail == null) { + Toast.makeText(applicationContext, "잘못된 요청입니다.", Toast.LENGTH_LONG).show() + finish() + } + + bindData() + binding.etTitle.setText(roomDetail!!.title) + binding.etContent.setText(roomDetail.notice) + binding.etNumberOfPeople.setText(roomDetail.numberOfParticipantsTotal.toString()) + viewModel.setRoomDetail(roomDetail = roomDetail) + } + + @SuppressLint("ClickableViewAccessibility") + override fun setupView() { + loadingDialog = LoadingDialog(this, layoutInflater) + + binding.toolbar.tvBack.text = "라이브 수정" + binding.toolbar.tvBack.setOnClickListener { finish() } + + binding.tvReservationDate.setOnClickListener { + val reservationDate = viewModel.beginDate.split("-") + val datePicker: DatePickerDialog + + if (reservationDate.isNotEmpty() && reservationDate.size == 3) { + datePicker = DatePickerDialog( + this, + R.style.DatePickerStyle, + datePickerDialogListener, + reservationDate[0].toInt(), + reservationDate[1].toInt() - 1, + reservationDate[2].toInt() + ) + } else { + val dateString = SimpleDateFormat( + "yyyy.MM.dd", + Locale.getDefault() + ).format(Date()).split(".") + + datePicker = DatePickerDialog( + this, + R.style.DatePickerStyle, + datePickerDialogListener, + dateString[0].toInt(), + dateString[1].toInt() - 1, + dateString[2].toInt() + ) + } + + datePicker.show() + } + binding.tvReservationTime.setOnClickListener { + val reservationTime = viewModel.beginTime.split(":") + val timePicker: TimePickerDialog + + if (reservationTime.isNotEmpty() && reservationTime.size == 2) { + timePicker = TimePickerDialog( + this, + R.style.TimePickerStyle, + timePickerDialogListener, + reservationTime[0].toInt(), + reservationTime[1].toInt(), + false + ) + } else { + val timeString = SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(Date()).split(":") + + timePicker = TimePickerDialog( + this, + R.style.TimePickerStyle, + timePickerDialogListener, + timeString[0].toInt(), + timeString[1].toInt(), + false + ) + } + + timePicker.show() + } + binding.tvUpdate.setOnClickListener { + binding.tvUpdate.isEnabled = false + + viewModel.updateLiveRoom { finish() } + + handler.postDelayed( + { binding.tvUpdate.isEnabled = true }, + 3000 + ) + } + + binding.etContent.setOnTouchListener { view, motionEvent -> + view.parent.parent.requestDisallowInterceptTouchEvent(true) + if ((motionEvent.action and MotionEvent.ACTION_MASK) == MotionEvent.ACTION_UP) { + view.parent.parent.requestDisallowInterceptTouchEvent(false) + } + false + } + } + + @SuppressLint("SetTextI18n") + private fun bindData() { + compositeDisposable.add( + binding.etTitle.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + viewModel.title = it.toString() + } + ) + + compositeDisposable.add( + binding.etContent.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + binding.tvNumberOfCharacters.text = "${it.length}자" + viewModel.content = it.toString() + } + ) + + compositeDisposable.add( + binding.etNumberOfPeople.textChanges().skip(1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + if (it.isEmpty()) { + viewModel.numberOfPeople = 0 + } else { + viewModel.numberOfPeople = it.toString().toInt() + } + } + ) + + 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() + } + } + + viewModel.reservationDateLiveData.observe(this) { + binding.tvReservationDate.text = it + } + + viewModel.reservationTimeLiveData.observe(this) { + binding.tvReservationTime.text = it + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/LiveRoomEditViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/LiveRoomEditViewModel.kt new file mode 100644 index 0000000..78697e1 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/update/LiveRoomEditViewModel.kt @@ -0,0 +1,180 @@ +package kr.co.vividnext.sodalive.live.room.update + +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.extensions.convertDateFormat +import kr.co.vividnext.sodalive.live.LiveRepository +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.Locale +import java.util.TimeZone + +class LiveRoomEditViewModel( + private val repository: LiveRepository +) : BaseViewModel() { + + private val _reservationDateLiveData = MutableLiveData("날짜를 선택해주세요") + val reservationDateLiveData: LiveData + get() = _reservationDateLiveData + + private val _reservationTimeLiveData = MutableLiveData("시간을 설정해주세요") + val reservationTimeLiveData: LiveData + get() = _reservationTimeLiveData + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private var _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private lateinit var roomDetail: GetRoomDetailResponse + + var title = "" + var content = "" + var numberOfPeople = 0 + var beginDate = "" + var beginTime = "" + var beginDateTimeStr = "" + + fun setReservationDate(dateString: String) { + _reservationDateLiveData.postValue(dateString) + } + + fun setReservationTime(timeString: String) { + _reservationTimeLiveData.postValue(timeString) + } + + fun updateLiveRoom(onSuccess: () -> Unit) { + if (!_isLoading.value!! && validateData()) { + _isLoading.value = true + val request = EditLiveRoomInfoRequest( + title = if (title != roomDetail.title) { + title + } else { + null + }, + notice = if (content != roomDetail.notice) { + content + } else { + null + }, + numberOfPeople = if (numberOfPeople != roomDetail.numberOfParticipantsTotal) { + numberOfPeople + } else { + null + }, + beginDateTimeString = if (beginDateTimeStr != "$beginDate $beginTime") { + "$beginDate $beginTime" + } else { + null + }, + timezone = TimeZone.getDefault().id + ) + + if ( + request.title == null && + request.notice == null && + request.numberOfPeople == null && + request.beginDateTimeString == null + ) { + _toastLiveData.value = "변경사항이 없습니다." + _isLoading.value = false + return + } + + val requestJson = Gson().toJson(request) + + compositeDisposable.add( + repository.editLiveRoomInfo( + roomId = roomDetail.roomId, + request = requestJson.toRequestBody("text/plain".toMediaType()), + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (it.success && it.data != null) { + _toastLiveData.value = "라이브 정보가 수정 되었습니다." + onSuccess() + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "라이브 정보를 수정 하지 못했습니다.\n다시 시도해 주세요." + ) + } + } + _isLoading.postValue(false) + }, + { + _isLoading.postValue(false) + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue( + "라이브 정보를 수정 하지 못했습니다.\n다시 시도해 주세요." + ) + } + ) + ) + } + } + + fun setRoomDetail(roomDetail: GetRoomDetailResponse) { + this.roomDetail = roomDetail + val date = roomDetail.beginDateTime.convertDateFormat( + from = "yyyy.MM.dd EEE hh:mm a", + to = "yyyy.MM.dd", + inputLocale = Locale.ENGLISH + ) + + val time = roomDetail.beginDateTime.convertDateFormat( + from = "yyyy.MM.dd EEE hh:mm a", + to = "a hh:mm", + inputLocale = Locale.ENGLISH + ) + + _reservationDateLiveData.value = date + _reservationTimeLiveData.value = time + + beginDate = date.convertDateFormat( + from = "yyyy.MM.dd", + to = "yyyy-MM-dd", + ) + + beginTime = time.convertDateFormat( + from = "a hh:mm", + to = "HH:mm", + ) + + beginDateTimeStr = "$beginDate $beginTime" + } + + private fun validateData(): Boolean { + if (title.isBlank()) { + _toastLiveData.postValue("제목을 입력해주세요.") + return false + } + + if (content.isBlank() || content.length < 5) { + _toastLiveData.postValue("내용을 5자 이상 입력해주세요.") + return false + } + + if (numberOfPeople < 3 || numberOfPeople > 999) { + _toastLiveData.postValue("인원을 3~999명 사이로 입력해주세요.") + return false + } + + return true + } +} diff --git a/app/src/main/res/drawable-xxhdpi/ic_circle_x.png b/app/src/main/res/drawable-xxhdpi/ic_circle_x.png new file mode 100644 index 0000000..41cc8d7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_circle_x.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_select_check.png b/app/src/main/res/drawable-xxhdpi/ic_select_check.png new file mode 100644 index 0000000..b7ba15b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_select_check.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tag_check.png b/app/src/main/res/drawable-xxhdpi/ic_tag_check.png new file mode 100644 index 0000000..0243889 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tag_check.png differ diff --git a/app/src/main/res/drawable-xxhdpi/img_compleate_book.png b/app/src/main/res/drawable-xxhdpi/img_compleate_book.png new file mode 100644 index 0000000..a9ec0af Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/img_compleate_book.png differ diff --git a/app/src/main/res/drawable/bg_live_room_price_select.xml b/app/src/main/res/drawable/bg_live_room_price_select.xml new file mode 100644 index 0000000..7526c8e --- /dev/null +++ b/app/src/main/res/drawable/bg_live_room_price_select.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_13_3_3e3358.xml b/app/src/main/res/drawable/bg_round_corner_13_3_3e3358.xml new file mode 100644 index 0000000..f79d25f --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_13_3_3e3358.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_24_3_339970ff_9970ff.xml b/app/src/main/res/drawable/bg_round_corner_24_3_339970ff_9970ff.xml new file mode 100644 index 0000000..69ab8aa --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_24_3_339970ff_9970ff.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_24_3_9970ff.xml b/app/src/main/res/drawable/bg_round_corner_24_3_9970ff.xml new file mode 100644 index 0000000..9eb276c --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_24_3_9970ff.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_6_7_1f1734.xml b/app/src/main/res/drawable/bg_round_corner_6_7_1f1734.xml new file mode 100644 index 0000000..3459572 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_6_7_1f1734.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_6_7_232323_777777.xml b/app/src/main/res/drawable/bg_round_corner_6_7_232323_777777.xml new file mode 100644 index 0000000..18b009e --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_6_7_232323_777777.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_6_7_333333_9970ff.xml b/app/src/main/res/drawable/bg_round_corner_6_7_333333_9970ff.xml new file mode 100644 index 0000000..9c6d1ad --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_6_7_333333_9970ff.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_6_7_4d9970ff_9970ff.xml b/app/src/main/res/drawable/bg_round_corner_6_7_4d9970ff_9970ff.xml new file mode 100644 index 0000000..65d78c1 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_6_7_4d9970ff_9970ff.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_live_reservation_complete.xml b/app/src/main/res/layout/activity_live_reservation_complete.xml new file mode 100644 index 0000000..cd6bfda --- /dev/null +++ b/app/src/main/res/layout/activity_live_reservation_complete.xml @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_live_room_create.xml b/app/src/main/res/layout/activity_live_room_create.xml new file mode 100644 index 0000000..24f95c3 --- /dev/null +++ b/app/src/main/res/layout/activity_live_room_create.xml @@ -0,0 +1,783 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_live_room_edit.xml b/app/src/main/res/layout/activity_live_room_edit.xml new file mode 100644 index 0000000..99f0cfc --- /dev/null +++ b/app/src/main/res/layout/activity_live_room_edit.xml @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_live.xml b/app/src/main/res/layout/dialog_live.xml new file mode 100644 index 0000000..8eda466 --- /dev/null +++ b/app/src/main/res/layout/dialog_live.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_live_input.xml b/app/src/main/res/layout/dialog_live_input.xml new file mode 100644 index 0000000..690d101 --- /dev/null +++ b/app/src/main/res/layout/dialog_live_input.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_live_room_password.xml b/app/src/main/res/layout/dialog_live_room_password.xml new file mode 100644 index 0000000..d07dfd0 --- /dev/null +++ b/app/src/main/res/layout/dialog_live_room_password.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_live_tag.xml b/app/src/main/res/layout/fragment_live_tag.xml new file mode 100644 index 0000000..b81d7e6 --- /dev/null +++ b/app/src/main/res/layout/fragment_live_tag.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_live_tag.xml b/app/src/main/res/layout/item_live_tag.xml new file mode 100644 index 0000000..6c29764 --- /dev/null +++ b/app/src/main/res/layout/item_live_tag.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_live_tag_selected.xml b/app/src/main/res/layout/item_live_tag_selected.xml new file mode 100644 index 0000000..31622c2 --- /dev/null +++ b/app/src/main/res/layout/item_live_tag_selected.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3f10932..1d8a96e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -27,12 +27,15 @@ #352953 #664aab #232323 + #525252 + #DD4500 + #1F1734 + #333333 #B3909090 #88909090 #339970FF #7FE2E2E2 #4D9970FF - #525252 - #DD4500 + #A285EB diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c742e17..6785574 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -26,4 +26,37 @@ + + + + + + + +