diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 37ce2af8e..bc81cd5e9 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -5,6 +5,8 @@ on: branches: - "develop" - "release*" + paths: + - 'android/**' defaults: run: diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt index 4bf4eec18..eaa770bf0 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt @@ -18,6 +18,7 @@ import androidx.databinding.BindingAdapter import com.bumptech.glide.Glide import com.zzang.chongdae.R import com.zzang.chongdae.domain.model.OfferingCondition +import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -196,6 +197,12 @@ fun TextView.bindFormattedDate(datetime: LocalDateTime?) { datetime?.format(DateTimeFormatter.ofPattern(context.getString(R.string.all_due_datetime))) } +@BindingAdapter("formattedOnlyDate") +fun TextView.bindFormattedOnlyDate(datetime: LocalDate?) { + this.text = + datetime?.format(DateTimeFormatter.ofPattern(context.getString(R.string.all_due_datetime))) +} + @BindingAdapter("currentCount", "totalCount", "condition") fun TextView.bindStatusComment( currentCount: Int, diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt index f3ab4ed57..8d5056e87 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt @@ -24,6 +24,8 @@ import com.zzang.chongdae.databinding.DialogUpdateStatusBinding import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener import com.zzang.chongdae.presentation.view.commentdetail.adapter.comment.CommentAdapter import com.zzang.chongdae.presentation.view.commentdetail.adapter.participant.ParticipantAdapter +import com.zzang.chongdae.presentation.view.commentdetail.event.CommentDetailEvent +import com.zzang.chongdae.presentation.view.commentdetail.event.OnUpdateStatusClickListener import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -103,19 +105,14 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { private fun setUpObserve() { observeComments() observeParticipants() - observeUpdateOfferingEvent() - observeReportEvent() - observeExitOfferingEvent() - observeBackEvent() - observeErrorEvent() + observeEvent() } private fun observeComments() { viewModel.comments.observe(this) { comments -> - commentAdapter.submitList(comments) { - binding.rvComments.doOnPreDraw { - binding.rvComments.scrollToPosition(comments.size - 1) - } + commentAdapter.submitComments(comments) + binding.rvComments.doOnPreDraw { + binding.rvComments.scrollToPosition(comments.size - 1) } } } @@ -128,12 +125,39 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { } } - private fun observeReportEvent() { - viewModel.reportEvent.observe(this) { reportUrlId -> - openUrlInBrowser(getString(reportUrlId)) + private fun observeEvent() { + viewModel.event.observe(this) { event -> + event.getContentIfNotHandled()?.let { handleEvent(it) } + } + } + + private fun handleEvent(event: CommentDetailEvent) { + when (event) { + is CommentDetailEvent.BackPressed -> finish() + is CommentDetailEvent.ShowError -> showError(event.message) + is CommentDetailEvent.ShowReport -> reportEvent(event.reportUrlId) + is CommentDetailEvent.ShowUpdateStatusDialog -> showUpdateStatusDialog() + is CommentDetailEvent.ShowAlert -> showExitDialog() + is CommentDetailEvent.ExitOffering -> exitOfferingEvent() + is CommentDetailEvent.AlertCancelled -> cancelDialog() } } + private fun showError(message: String) { + toast?.cancel() + toast = + Toast.makeText( + this, + message, + Toast.LENGTH_SHORT, + ) + toast?.show() + } + + private fun reportEvent(reportUrlId: Int) { + openUrlInBrowser(getString(reportUrlId)) + } + private fun openUrlInBrowser(url: String) { val intent = Intent(Intent.ACTION_VIEW).apply { @@ -142,52 +166,26 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { startActivity(intent) } - private fun observeUpdateOfferingEvent() { - viewModel.showStatusDialogEvent.observe(this) { - showUpdateStatusDialog() - } - } - - private fun observeExitOfferingEvent() { - viewModel.onExitOfferingEvent.observe(this) { - firebaseAnalyticsManager.logSelectContentEvent( - id = "exit_offering_event", - name = "exit_offering_event", - contentType = "button", - ) - finish() - dialog.dismiss() - } - viewModel.showAlertEvent.observe(this) { - val alertBinding = DialogAlertBinding.inflate(layoutInflater, null, false) - alertBinding.tvDialogMessage.text = getString(R.string.comment_detail_exit_alert) - alertBinding.listener = viewModel - - dialog.setContentView(alertBinding.root) - dialog.show() - } - viewModel.alertCancelEvent.observe(this) { - dialog.dismiss() - } + private fun exitOfferingEvent() { + firebaseAnalyticsManager.logSelectContentEvent( + id = "exit_offering_event", + name = "exit_offering_event", + contentType = "button", + ) + finish() + dialog.dismiss() } - private fun observeBackEvent() { - viewModel.onBackPressedEvent.observe(this) { - finish() - } + private fun showExitDialog() { + val alertBinding = DialogAlertBinding.inflate(layoutInflater, null, false) + alertBinding.tvDialogMessage.text = getString(R.string.comment_detail_exit_alert) + alertBinding.listener = viewModel + dialog.setContentView(alertBinding.root) + dialog.show() } - private fun observeErrorEvent() { - viewModel.errorEvent.observe(this) { - toast?.cancel() - toast = - Toast.makeText( - this, - it, - Toast.LENGTH_SHORT, - ) - toast?.show() - } + private fun cancelDialog() { + dialog.dismiss() } private fun showUpdateStatusDialog() { diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt index d67785fb6..e81fbfba5 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt @@ -17,8 +17,8 @@ import com.zzang.chongdae.domain.model.Comment import com.zzang.chongdae.domain.repository.CommentDetailRepository import com.zzang.chongdae.domain.repository.OfferingRepository import com.zzang.chongdae.domain.repository.ParticipantRepository -import com.zzang.chongdae.presentation.util.MutableSingleLiveData -import com.zzang.chongdae.presentation.util.SingleLiveData +import com.zzang.chongdae.presentation.util.Event +import com.zzang.chongdae.presentation.view.commentdetail.event.CommentDetailEvent import com.zzang.chongdae.presentation.view.commentdetail.model.information.CommentOfferingInfoUiModel import com.zzang.chongdae.presentation.view.commentdetail.model.information.CommentOfferingInfoUiModel.Companion.toUiModel import com.zzang.chongdae.presentation.view.commentdetail.model.meeting.MeetingsUiModel @@ -49,12 +49,13 @@ class CommentDetailViewModel fun create(offeringId: Long): CommentDetailViewModel } - private var cachedComments: List = emptyList() private var pollJob: Job? = null + val commentContent = MutableLiveData("") private val _comments: MutableLiveData> = MutableLiveData() val comments: LiveData> get() = _comments + private var cachedComments: List = emptyList() private val _commentOfferingInfo = MutableLiveData() val commentOfferingInfo: LiveData get() = _commentOfferingInfo @@ -62,32 +63,14 @@ class CommentDetailViewModel private val _meetings = MutableLiveData() val meetings: LiveData get() = _meetings - private val _isCollapsibleViewVisible = MutableLiveData(false) - val isCollapsibleViewVisible: LiveData get() = _isCollapsibleViewVisible - private val _participants = MutableLiveData() val participants: LiveData get() = _participants - private val _showStatusDialogEvent = MutableLiveData() - val showStatusDialogEvent: LiveData get() = _showStatusDialogEvent - - private val _reportEvent: MutableSingleLiveData = MutableSingleLiveData() - val reportEvent: SingleLiveData get() = _reportEvent - - private val _onExitOfferingEvent = MutableSingleLiveData() - val onExitOfferingEvent: SingleLiveData get() = _onExitOfferingEvent - - private val _onBackPressedEvent = MutableSingleLiveData() - val onBackPressedEvent: SingleLiveData get() = _onBackPressedEvent - - private val _errorEvent = MutableLiveData() - val errorEvent: MutableLiveData get() = _errorEvent - - private val _showAlertEvent = MutableSingleLiveData() - val showAlertEvent: SingleLiveData get() = _showAlertEvent + private val _isCollapsibleViewVisible = MutableLiveData(false) + val isCollapsibleViewVisible: LiveData get() = _isCollapsibleViewVisible - private val _alertCancelEvent = MutableSingleLiveData() - val alertCancelEvent: SingleLiveData get() = _alertCancelEvent + private val _event = MutableLiveData>() + val event: LiveData> get() = _event init { startPolling() @@ -97,7 +80,7 @@ class CommentDetailViewModel } private fun startPolling() { - pollJob?.cancel() + stopPolling() pollJob = viewModelScope.launch { while (this.isActive) { @@ -107,29 +90,40 @@ class CommentDetailViewModel } } + private fun handleNetworkError( + error: DataError.Network, + retryAction: suspend () -> Unit, + ) { + when (error) { + DataError.Network.UNAUTHORIZED -> { + viewModelScope.launch { + when (authRepository.saveRefresh()) { + is Result.Success -> retryAction() + is Result.Error -> + _event.value = + Event(CommentDetailEvent.ShowError("로그아웃 후 다시 진행해주세요.")) + } + } + } + + else -> _event.value = Event(CommentDetailEvent.ShowError(error.name)) + } + } + private fun updateCommentInfo() { viewModelScope.launch { when (val result = commentDetailRepository.fetchCommentOfferingInfo(offeringId)) { is Result.Success -> _commentOfferingInfo.value = result.data.toUiModel() is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - when (authRepository.saveRefresh()) { - is Result.Success -> updateCommentInfo() - is Result.Error -> return@launch - } - } - - else -> { - errorEvent.value = result.error.name - } + handleNetworkError(result.error) { + updateCommentInfo() } } } } fun updateOfferingEvent() { - _showStatusDialogEvent.value = Unit + _event.value = Event(CommentDetailEvent.ShowUpdateStatusDialog) } fun updateOfferingStatus() { @@ -137,17 +131,8 @@ class CommentDetailViewModel when (val result = commentDetailRepository.updateOfferingStatus(offeringId)) { is Result.Success -> updateCommentInfo() is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - when (authRepository.saveRefresh()) { - is Result.Success -> updateOfferingStatus() - is Result.Error -> return@launch - } - } - - else -> { - errorEvent.value = result.error.name - } + handleNetworkError(result.error) { + updateOfferingStatus() } } } @@ -165,18 +150,8 @@ class CommentDetailViewModel } is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - when (authRepository.saveRefresh()) { - is Result.Success -> loadComments() - is Result.Error -> return@launch - } - } - - else -> { - pollJob?.cancel() - errorEvent.value = result.error.name - } + handleNetworkError(result.error) { + loadComments() } } } @@ -184,27 +159,14 @@ class CommentDetailViewModel fun postComment() { val content = commentContent.value?.trim() - if (content.isNullOrEmpty()) { - return - } + if (content.isNullOrEmpty()) return + viewModelScope.launch { when (val result = commentDetailRepository.saveComment(offeringId, content)) { - is Result.Success -> { - commentContent.value = "" - } - + is Result.Success -> commentContent.value = "" is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - when (authRepository.saveRefresh()) { - is Result.Success -> postComment() - is Result.Error -> return@launch - } - } - - else -> { - errorEvent.value = result.error.name - } + handleNetworkError(result.error) { + postComment() } } } @@ -222,17 +184,8 @@ class CommentDetailViewModel when (val result = participantRepository.fetchParticipants(offeringId)) { is Result.Success -> _participants.value = result.data.toUiModel() is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - when (authRepository.saveRefresh()) { - is Result.Success -> loadParticipants() - is Result.Error -> return@launch - } - } - - else -> { - errorEvent.value = result.error.name - } + handleNetworkError(result.error) { + loadParticipants() } } } @@ -243,50 +196,43 @@ class CommentDetailViewModel when (val result = offeringRepository.fetchMeetings(offeringId)) { is Result.Success -> _meetings.value = result.data.toUiModel() is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - when (authRepository.saveRefresh()) { - is Result.Success -> loadMeetings() - is Result.Error -> return@launch - } - } - - else -> { - errorEvent.value = result.error.name - } + handleNetworkError(result.error) { + loadMeetings() } } } } fun onClickReport() { - _reportEvent.setValue(R.string.report_url) + _event.value = Event(CommentDetailEvent.ShowReport(R.string.report_url)) } - fun exitOffering() { + private fun exitOffering() { viewModelScope.launch { when (val result = participantRepository.deleteParticipations(offeringId)) { is Result.Success -> { - _onExitOfferingEvent.setValue(Unit) - pollJob?.cancel() + _event.value = Event(CommentDetailEvent.ExitOffering) + stopPolling() } is Result.Error -> when (result.error) { DataError.Network.NULL -> { - _onExitOfferingEvent.setValue(Unit) - pollJob?.cancel() + _event.value = Event(CommentDetailEvent.ExitOffering) + stopPolling() } DataError.Network.UNAUTHORIZED -> { when (authRepository.saveRefresh()) { is Result.Success -> exitOffering() - is Result.Error -> return@launch + is Result.Error -> + _event.value = + Event(CommentDetailEvent.ShowError("로그아웃 후 다시 진행해주세요.")) } } else -> { - _errorEvent.value = result.error.name + return@launch } } } @@ -294,18 +240,30 @@ class CommentDetailViewModel } fun onBackClick() { - _onBackPressedEvent.setValue(Unit) + _event.value = Event(CommentDetailEvent.BackPressed) } - override fun onCleared() { - super.onCleared() - stopPolling() + fun onExitClick() { + _event.value = Event(CommentDetailEvent.ShowAlert) + } + + override fun onClickConfirm() { + exitOffering() + } + + override fun onClickCancel() { + _event.value = Event(CommentDetailEvent.AlertCancelled) } private fun stopPolling() { pollJob?.cancel() } + override fun onCleared() { + super.onCleared() + stopPolling() + } + companion object { @Suppress("UNCHECKED_CAST") fun getFactory( @@ -317,16 +275,4 @@ class CommentDetailViewModel } } } - - fun onExitClick() { - _showAlertEvent.setValue(Unit) - } - - override fun onClickConfirm() { - exitOffering() - } - - override fun onClickCancel() { - _alertCancelEvent.setValue(Unit) - } } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt index d07124302..9357a609e 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt @@ -5,15 +5,34 @@ import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemDateSeparatorBinding import com.zzang.chongdae.databinding.ItemMyCommentBinding import com.zzang.chongdae.databinding.ItemOtherCommentBinding import com.zzang.chongdae.domain.model.Comment -class CommentAdapter : ListAdapter(DIFF_CALLBACK) { - override fun getItemViewType(position: Int): Int { - return if (getItem(position).isMine) VIEW_TYPE_MY_COMMENT else VIEW_TYPE_OTHER_COMMENT +class CommentAdapter : ListAdapter(DIFF_CALLBACK) { + fun submitComments(comments: List) { + val newItems = mutableListOf() + + for (i in comments.indices) { + val currentComment = comments[i] + val previousComment = if (i > 0) comments[i - 1] else null + + if (previousComment == null || isDifferentDates(currentComment, previousComment)) { + newItems.add(CommentViewType.DateSeparator(currentComment)) + } + + newItems.add(CommentViewType.fromComment(currentComment)) + } + + submitList(newItems) } + private fun isDifferentDates( + currentComment: Comment, + previousComment: Comment, + ) = currentComment.commentCreatedAt.date != previousComment.commentCreatedAt.date + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, @@ -23,10 +42,17 @@ class CommentAdapter : ListAdapter(DIFF_CALLBA val binding = ItemMyCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false) MyCommentViewHolder(binding) } + VIEW_TYPE_OTHER_COMMENT -> { val binding = ItemOtherCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false) OtherCommentViewHolder(binding) } + + VIEW_TYPE_DATE_SEPARATOR -> { + val binding = ItemDateSeparatorBinding.inflate(LayoutInflater.from(parent.context), parent, false) + DateSeparatorViewHolder(binding) + } + else -> throw IllegalArgumentException("CommentAdapter viewType error") } } @@ -35,29 +61,49 @@ class CommentAdapter : ListAdapter(DIFF_CALLBA holder: RecyclerView.ViewHolder, position: Int, ) { - val comment = getItem(position) - when (holder.itemViewType) { - VIEW_TYPE_MY_COMMENT -> (holder as MyCommentViewHolder).bind(comment) - VIEW_TYPE_OTHER_COMMENT -> (holder as OtherCommentViewHolder).bind(comment) + when (val item = getItem(position)) { + is CommentViewType.MyComment -> (holder as MyCommentViewHolder).bind(item.comment) + is CommentViewType.OtherComment -> (holder as OtherCommentViewHolder).bind(item.comment) + is CommentViewType.DateSeparator -> (holder as DateSeparatorViewHolder).bind(item.comment) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is CommentViewType.MyComment -> VIEW_TYPE_MY_COMMENT + is CommentViewType.OtherComment -> VIEW_TYPE_OTHER_COMMENT + is CommentViewType.DateSeparator -> VIEW_TYPE_DATE_SEPARATOR } } companion object { private const val VIEW_TYPE_MY_COMMENT = 1 private const val VIEW_TYPE_OTHER_COMMENT = 2 + private const val VIEW_TYPE_DATE_SEPARATOR = 3 private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { + object : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: Comment, - newItem: Comment, + oldItem: CommentViewType, + newItem: CommentViewType, ): Boolean { - return oldItem == newItem + return when { + oldItem is CommentViewType.MyComment && newItem is CommentViewType.MyComment -> + oldItem.comment == newItem.comment + + oldItem is CommentViewType.OtherComment && newItem is CommentViewType.OtherComment -> + oldItem.comment == newItem.comment + + oldItem is CommentViewType.DateSeparator && newItem is CommentViewType.DateSeparator -> + oldItem.comment == newItem.comment + + else -> false + } } override fun areContentsTheSame( - oldItem: Comment, - newItem: Comment, + oldItem: CommentViewType, + newItem: CommentViewType, ): Boolean { return oldItem == newItem } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentViewType.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentViewType.kt new file mode 100644 index 000000000..8b8f30ec8 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentViewType.kt @@ -0,0 +1,21 @@ +package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment + +import com.zzang.chongdae.domain.model.Comment + +sealed class CommentViewType { + data class MyComment(val comment: Comment) : CommentViewType() + + data class OtherComment(val comment: Comment) : CommentViewType() + + data class DateSeparator(val comment: Comment) : CommentViewType() + + companion object { + fun fromComment(comment: Comment): CommentViewType { + return if (comment.isMine) { + MyComment(comment) + } else { + OtherComment(comment) + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/DateSeparatorViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/DateSeparatorViewHolder.kt new file mode 100644 index 000000000..332edbbdd --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/DateSeparatorViewHolder.kt @@ -0,0 +1,13 @@ +package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment + +import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemDateSeparatorBinding +import com.zzang.chongdae.domain.model.Comment + +class DateSeparatorViewHolder( + private val binding: ItemDateSeparatorBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(comment: Comment) { + binding.comment = comment + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/event/CommentDetailEvent.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/event/CommentDetailEvent.kt new file mode 100644 index 000000000..7e8b69708 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/event/CommentDetailEvent.kt @@ -0,0 +1,17 @@ +package com.zzang.chongdae.presentation.view.commentdetail.event + +sealed class CommentDetailEvent { + data object ShowUpdateStatusDialog : CommentDetailEvent() + + data object ExitOffering : CommentDetailEvent() + + data object BackPressed : CommentDetailEvent() + + data class ShowError(val message: String) : CommentDetailEvent() + + data class ShowReport(val reportUrlId: Int) : CommentDetailEvent() + + data object ShowAlert : CommentDetailEvent() + + data object AlertCancelled : CommentDetailEvent() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/OnUpdateStatusClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/event/OnUpdateStatusClickListener.kt similarity index 58% rename from android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/OnUpdateStatusClickListener.kt rename to android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/event/OnUpdateStatusClickListener.kt index ccc427296..21c8d5011 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/OnUpdateStatusClickListener.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/event/OnUpdateStatusClickListener.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.presentation.view.commentdetail +package com.zzang.chongdae.presentation.view.commentdetail.event interface OnUpdateStatusClickListener { fun onSubmitClick() diff --git a/android/app/src/main/res/layout/dialog_update_status.xml b/android/app/src/main/res/layout/dialog_update_status.xml index 72752abb0..c94fb6707 100644 --- a/android/app/src/main/res/layout/dialog_update_status.xml +++ b/android/app/src/main/res/layout/dialog_update_status.xml @@ -11,7 +11,7 @@ + type="com.zzang.chongdae.presentation.view.commentdetail.event.OnUpdateStatusClickListener" /> + - - diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 000000000..b3e26b4c6 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 000000000..b3e26b4c6 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java b/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java index 7183087f9..11a00d412 100644 --- a/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java +++ b/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java @@ -57,6 +57,7 @@ public CommentRoomAllResponse getAllCommentRoom(MemberEntity member) { List offeringIds = offeringMemberRepository.findOfferingIdsByMemberId(member.getId()); List responseItems = offeringIds.stream() .map(offeringId -> getCommentRoom(offeringId, member)) + .sorted() .toList(); return new CommentRoomAllResponse(responseItems); } diff --git a/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentLatestResponse.java b/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentLatestResponse.java index 118d2d55e..1faabc601 100644 --- a/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentLatestResponse.java +++ b/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentLatestResponse.java @@ -3,9 +3,23 @@ import com.zzang.chongdae.comment.repository.entity.CommentEntity; import java.time.LocalDateTime; -public record CommentLatestResponse(String content, LocalDateTime createdAt) { +public record CommentLatestResponse(String content, LocalDateTime createdAt) implements Comparable { public CommentLatestResponse(CommentEntity comment) { this(comment.getContent(), comment.getCreatedAt()); } + + @Override + public int compareTo(CommentLatestResponse other) { + if (this.createdAt == null && other.createdAt == null) { + return 0; + } + if (this.createdAt == null) { + return 1; + } + if (other.createdAt == null) { + return -1; + } + return other.createdAt.compareTo(this.createdAt); + } } diff --git a/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentRoomAllResponseItem.java b/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentRoomAllResponseItem.java index 0f1d4a977..841096a74 100644 --- a/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentRoomAllResponseItem.java +++ b/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentRoomAllResponseItem.java @@ -6,7 +6,7 @@ public record CommentRoomAllResponseItem(Long offeringId, String offeringTitle, Boolean isProposer, - CommentLatestResponse latestComment) { + CommentLatestResponse latestComment) implements Comparable{ public CommentRoomAllResponseItem(OfferingEntity offering, OfferingMemberEntity offeringMember, @@ -25,4 +25,9 @@ public CommentRoomAllResponseItem(Long offeringId, offeringMember.isProposer(), latestComment); } + + @Override + public int compareTo(CommentRoomAllResponseItem other) { + return this.latestComment.compareTo(other.latestComment); + } } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java b/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java index 6f28c17cf..d68c1ef2c 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java @@ -35,7 +35,7 @@ public UpdatedOffering(String title, String productUrl, String thumbnailUrl, Int private void validateMeetingDate() { LocalDateTime today = LocalDateTime.now(); - if (meetingDate.isBefore(today) || meetingDate.isEqual(today)) { + if (meetingDate.isBefore(today)) { throw new MarketException(OfferingErrorCode.CANNOT_UPDATE_BEFORE_NOW_MEETING_DATE); } } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/HighDiscountOfferingStrategy.java b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/HighDiscountOfferingStrategy.java index 0d00c6805..a9e1a6872 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/HighDiscountOfferingStrategy.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/HighDiscountOfferingStrategy.java @@ -2,6 +2,7 @@ import com.zzang.chongdae.offering.repository.OfferingRepository; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import java.util.Comparator; import java.util.List; import org.springframework.data.domain.Pageable; @@ -12,18 +13,39 @@ public HighDiscountOfferingStrategy(OfferingRepository offeringRepository) { } @Override - protected List fetchOfferingsWithoutLastId(String searchKeyword, Pageable pageable) { + protected List fetchWithoutLast(Long outOfRangeId, String searchKeyword, Pageable pageable) { double outOfRangeDiscountRate = 100; - Long outOfRangeId = findOutOfRangeId(); - return offeringRepository.findHighDiscountOfferingsWithKeyword( - outOfRangeDiscountRate, outOfRangeId, searchKeyword, pageable); + return fetchOfferings(outOfRangeId, outOfRangeDiscountRate, searchKeyword, pageable); } @Override - protected List fetchOfferingsWithLastOffering( - OfferingEntity lastOffering, String searchKeyword, Pageable pageable) { + protected List fetchWithLast(OfferingEntity lastOffering, String searchKeyword, Pageable pageable) { + Long lastId = lastOffering.getId(); Double lastDiscountRate = lastOffering.getDiscountRate(); - return offeringRepository.findHighDiscountOfferingsWithKeyword( - lastDiscountRate, lastOffering.getId(), searchKeyword, pageable); + return fetchOfferings(lastId, lastDiscountRate, searchKeyword, pageable); + } + + private List fetchOfferings(Long lastId, double lastDiscountRate, + String searchKeyword, Pageable pageable) { + if (searchKeyword == null) { + return offeringRepository.findHighDiscountOfferingsWithoutKeyword(lastDiscountRate, lastId, pageable); + } + List offeringsSearchedByTitle = offeringRepository.findHighDiscountOfferingsWithTitleKeyword( + lastDiscountRate, + lastId, + searchKeyword, + pageable); + List offeringsSearchedByMeetingAddress = offeringRepository.findHighDiscountOfferingsWithMeetingAddressKeyword( + lastDiscountRate, + lastId, + searchKeyword, + pageable); + return concat(pageable, sortCondition(), offeringsSearchedByTitle, offeringsSearchedByMeetingAddress); + } + + private Comparator sortCondition() { + return Comparator + .comparing(OfferingEntity::getDiscountRate) + .thenComparing(OfferingEntity::getId, Comparator.reverseOrder()); } } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/ImminentOfferingStrategy.java b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/ImminentOfferingStrategy.java index 5966f6c5c..04d699386 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/ImminentOfferingStrategy.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/ImminentOfferingStrategy.java @@ -3,6 +3,7 @@ import com.zzang.chongdae.offering.repository.OfferingRepository; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; import java.time.LocalDateTime; +import java.util.Comparator; import java.util.List; import org.springframework.data.domain.Pageable; @@ -13,19 +14,39 @@ public ImminentOfferingStrategy(OfferingRepository offeringRepository) { } @Override - protected List fetchOfferingsWithoutLastId(String searchKeyword, Pageable pageable) { + protected List fetchWithoutLast(Long outOfRangeId, String searchKeyword, Pageable pageable) { LocalDateTime outOfRangeMeetingDate = LocalDateTime.now(); - Long outOfRangeId = findOutOfRangeId(); - return offeringRepository.findImminentOfferingsWithKeyword( - outOfRangeMeetingDate, outOfRangeId, searchKeyword, pageable); + return fetchOfferings(outOfRangeId, outOfRangeMeetingDate, searchKeyword, pageable); } @Override - protected List fetchOfferingsWithLastOffering( - OfferingEntity lastOffering, String searchKeyword, Pageable pageable) { - LocalDateTime lastMeetingDate = lastOffering.getMeetingDate(); + protected List fetchWithLast(OfferingEntity lastOffering, String searchKeyword, Pageable pageable) { Long lastId = lastOffering.getId(); - return offeringRepository.findImminentOfferingsWithKeyword( - lastMeetingDate, lastId, searchKeyword, pageable); + LocalDateTime lastMeetingDate = lastOffering.getMeetingDate(); + return fetchOfferings(lastId, lastMeetingDate, searchKeyword, pageable); + } + + private List fetchOfferings(Long lastId, LocalDateTime lastMeetingDate, + String searchKeyword, Pageable pageable) { + if (searchKeyword == null) { + return offeringRepository.findImminentOfferingsWithoutKeyword(lastMeetingDate, lastId, pageable); + } + List offeringsSearchedByTitle = offeringRepository.findImminentOfferingsWithTitleKeyword( + lastMeetingDate, + lastId, + searchKeyword, + pageable); + List offeringsSearchedByMeetingAddress = offeringRepository.findImminentOfferingsWithMeetingAddressKeyword( + lastMeetingDate, + lastId, + searchKeyword, + pageable); + return concat(pageable, sortCondition(), offeringsSearchedByTitle, offeringsSearchedByMeetingAddress); + } + + private Comparator sortCondition() { + return Comparator + .comparing(OfferingEntity::getMeetingDate) + .thenComparing(OfferingEntity::getId, Comparator.reverseOrder()); } } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/JoinableOfferingStrategy.java b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/JoinableOfferingStrategy.java index 53f39210e..7c15a3574 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/JoinableOfferingStrategy.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/JoinableOfferingStrategy.java @@ -12,14 +12,20 @@ public JoinableOfferingStrategy(OfferingRepository offeringRepository) { } @Override - protected List fetchOfferingsWithoutLastId(String searchKeyword, Pageable pageable) { - Long outOfRangeId = findOutOfRangeId(); - return offeringRepository.findJoinableOfferingsWithKeyword(outOfRangeId, searchKeyword, pageable); + protected List fetchWithoutLast(Long outOfRangeId, String searchKeyword, Pageable pageable) { + return fetchOfferings(outOfRangeId, searchKeyword, pageable); } @Override - protected List fetchOfferingsWithLastOffering(OfferingEntity lastOffering, String searchKeyword, - Pageable pageable) { - return offeringRepository.findJoinableOfferingsWithKeyword(lastOffering.getId(), searchKeyword, pageable); + protected List fetchWithLast(OfferingEntity lastOffering, String searchKeyword, Pageable pageable) { + Long lastId = lastOffering.getId(); + return fetchOfferings(lastId, searchKeyword, pageable); + } + + private List fetchOfferings(Long outOfRangeId, String searchKeyword, Pageable pageable) { + if (searchKeyword == null) { + return offeringRepository.findJoinableOfferingsWithoutKeyword(outOfRangeId, pageable); + } + return offeringRepository.findJoinableOfferingsWithKeyword(outOfRangeId, searchKeyword, pageable); } } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/OfferingFetchStrategy.java b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/OfferingFetchStrategy.java index 76343a80e..0a58f7dab 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/OfferingFetchStrategy.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/OfferingFetchStrategy.java @@ -4,8 +4,11 @@ import com.zzang.chongdae.offering.exception.OfferingErrorCode; import com.zzang.chongdae.offering.repository.OfferingRepository; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -16,22 +19,38 @@ public abstract class OfferingFetchStrategy { protected final OfferingRepository offeringRepository; - protected Long findOutOfRangeId() { - return Optional.ofNullable(offeringRepository.findMaxId()) - .orElse(0L) + OUT_OF_RANGE_ID_OFFSET; - } - - public List fetchOfferings(String searchKeyword, Long lastId, Pageable pageable) { + public List fetch(String searchKeyword, Long lastId, Pageable pageable) { if (lastId == null) { - return fetchOfferingsWithoutLastId(searchKeyword, pageable); + return fetchWithoutLast(outOfRangeId(), searchKeyword, pageable); } - OfferingEntity lastOffering = offeringRepository.findById(lastId) + return fetchWithLast(lastOffering(lastId), searchKeyword, pageable); + } + + private Long outOfRangeId() { + Long maxId = offeringRepository.findMaxId(); + return Optional.ofNullable(maxId).orElse(0L) + OUT_OF_RANGE_ID_OFFSET; + } + + private OfferingEntity lastOffering(Long lastId) { + return offeringRepository.findById(lastId) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); - return fetchOfferingsWithLastOffering(lastOffering, searchKeyword, pageable); } - protected abstract List fetchOfferingsWithoutLastId(String searchKeyword, Pageable pageable); + protected List concat(Pageable pageable, + Comparator sortCondition, + List... offerings) { + return Stream.of(offerings) + .flatMap(Collection::stream) + .sorted(sortCondition) + .limit(pageable.getPageSize()) + .toList(); + } + + protected abstract List fetchWithoutLast(Long outOfRangeId, + String searchKeyword, + Pageable pageable); - protected abstract List fetchOfferingsWithLastOffering( - OfferingEntity lastOffering, String searchKeyword, Pageable pageable); + protected abstract List fetchWithLast(OfferingEntity lastOffering, + String searchKeyword, + Pageable pageable); } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/RecentOfferingStrategy.java b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/RecentOfferingStrategy.java index 0fe1bc3b2..501a15aaf 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/RecentOfferingStrategy.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/domain/offeringfetchstrategy/RecentOfferingStrategy.java @@ -12,14 +12,20 @@ public RecentOfferingStrategy(OfferingRepository offeringRepository) { } @Override - protected List fetchOfferingsWithoutLastId(String searchKeyword, Pageable pageable) { - Long outOfRangeId = findOutOfRangeId(); - return offeringRepository.findRecentOfferingsWithKeyword(outOfRangeId, searchKeyword, pageable); + protected List fetchWithoutLast(Long outOfRangeId, String searchKeyword, Pageable pageable) { + return fetchOfferings(outOfRangeId, searchKeyword, pageable); } @Override - protected List fetchOfferingsWithLastOffering( - OfferingEntity lastOffering, String searchKeyword, Pageable pageable) { - return offeringRepository.findRecentOfferingsWithKeyword(lastOffering.getId(), searchKeyword, pageable); + protected List fetchWithLast(OfferingEntity lastOffering, String searchKeyword, Pageable pageable) { + Long lastOfferingId = lastOffering.getId(); + return fetchOfferings(lastOfferingId, searchKeyword, pageable); + } + + private List fetchOfferings(Long lastOfferingId, String searchKeyword, Pageable pageable) { + if (searchKeyword == null) { + return offeringRepository.findRecentOfferingsWithoutKeyword(lastOfferingId, pageable); + } + return offeringRepository.findRecentOfferingsWithKeyword(lastOfferingId, searchKeyword, pageable); } } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java index 4e3201501..c6c3d4063 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java @@ -1,7 +1,6 @@ package com.zzang.chongdae.offering.repository; import com.zzang.chongdae.member.repository.entity.MemberEntity; -import com.zzang.chongdae.offering.domain.OfferingStatus; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; import java.time.LocalDateTime; import java.util.List; @@ -31,7 +30,15 @@ public interface OfferingRepository extends JpaRepository SELECT o FROM OfferingEntity o WHERE o.id < :lastId - AND (:keyword IS NULL OR o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%) + ORDER BY o.id DESC + """) + List findRecentOfferingsWithoutKeyword(Long lastId, Pageable pageable); + + @Query(""" + SELECT o + FROM OfferingEntity o + WHERE o.id < :lastId + AND (o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%) ORDER BY o.id DESC """) List findRecentOfferingsWithKeyword(Long lastId, String keyword, Pageable pageable); @@ -39,32 +46,82 @@ public interface OfferingRepository extends JpaRepository @Query(""" SELECT o FROM OfferingEntity o - WHERE (o.offeringStatus = 'IMMINENT') + WHERE (o.meetingDate > :lastMeetingDate OR (o.meetingDate = :lastMeetingDate AND o.id < :lastId)) + AND (o.offeringStatus = 'IMMINENT') + ORDER BY o.meetingDate ASC, o.id DESC + """) + List findImminentOfferingsWithoutKeyword( + LocalDateTime lastMeetingDate, Long lastId, Pageable pageable); + + @Query(""" + SELECT o + FROM OfferingEntity o + WHERE (o.meetingAddress LIKE :keyword%) + AND (o.offeringStatus = 'IMMINENT') + AND (o.meetingDate > :lastMeetingDate OR (o.meetingDate = :lastMeetingDate AND o.id < :lastId)) + ORDER BY o.meetingDate ASC, o.id DESC + """) + List findImminentOfferingsWithMeetingAddressKeyword( + LocalDateTime lastMeetingDate, Long lastId, String keyword, Pageable pageable); + + @Query(""" + SELECT o + FROM OfferingEntity o + WHERE (o.title LIKE :keyword%) + AND (o.offeringStatus = 'IMMINENT') AND (o.meetingDate > :lastMeetingDate OR (o.meetingDate = :lastMeetingDate AND o.id < :lastId)) - AND (:keyword IS NULL OR o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%) ORDER BY o.meetingDate ASC, o.id DESC """) - List findImminentOfferingsWithKeyword( + List findImminentOfferingsWithTitleKeyword( LocalDateTime lastMeetingDate, Long lastId, String keyword, Pageable pageable); @Query(""" SELECT o FROM OfferingEntity o - WHERE (o.offeringStatus != 'CONFIRMED') - AND (o.discountRate IS NOT NULL) - AND (o.discountRate < :lastDiscountRate OR (o.discountRate = :lastDiscountRate AND o.id < :lastId)) - AND (:keyword IS NULL OR o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%) + WHERE ((o.discountRate < :lastDiscountRate) or (o.discountRate = :lastDiscountRate AND o.id < :lastId)) + AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')) + ORDER BY o.discountRate DESC, o.id DESC + """) + List findHighDiscountOfferingsWithoutKeyword( + double lastDiscountRate, Long lastId, Pageable pageable); + + @Query(""" + SELECT o + FROM OfferingEntity o + WHERE (o.title LIKE :keyword%) + AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')) + AND ((o.discountRate < :lastDiscountRate) or (o.discountRate = :lastDiscountRate AND o.id < :lastId)) + ORDER BY o.discountRate DESC, o.id DESC + """) + List findHighDiscountOfferingsWithTitleKeyword( + double lastDiscountRate, Long lastId, String keyword, Pageable pageable); + + @Query(""" + SELECT o + FROM OfferingEntity o + WHERE (o.meetingAddress LIKE :keyword%) + AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')) + AND ((o.discountRate < :lastDiscountRate) or (o.discountRate = :lastDiscountRate AND o.id < :lastId)) ORDER BY o.discountRate DESC, o.id DESC """) - List findHighDiscountOfferingsWithKeyword( + List findHighDiscountOfferingsWithMeetingAddressKeyword( double lastDiscountRate, Long lastId, String keyword, Pageable pageable); @Query(""" SELECT o FROM OfferingEntity o - WHERE (o.offeringStatus IN ('AVAILABLE', 'IMMINENT')) - AND (o.id < :lastId) - AND (:keyword IS NULL OR o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%) + WHERE (o.id < :lastId) + AND (o.offeringStatus IN ('AVAILABLE', 'IMMINENT')) + ORDER BY o.id DESC + """) + List findJoinableOfferingsWithoutKeyword(Long lastId, Pageable pageable); + + @Query(""" + SELECT o + FROM OfferingEntity o + WHERE (o.id < :lastId) + AND (o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%) + AND (o.offeringStatus IN ('AVAILABLE', 'IMMINENT')) ORDER BY o.id DESC """) List findJoinableOfferingsWithKeyword(Long lastId, String keyword, Pageable pageable); @@ -76,8 +133,7 @@ List findHighDiscountOfferingsWithKeyword( SELECT o FROM OfferingEntity o WHERE o.meetingDate = :meetingDate - AND o.offeringStatus != :offeringStatus + AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')) """) - List findByMeetingDateAndOfferingStatusNot(LocalDateTime meetingDate, - OfferingStatus offeringStatus); + List findByMeetingDateAndOfferingStatusNotConfirmed(LocalDateTime meetingDate); } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingFetcher.java b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingFetcher.java index 62a6967af..0f8019cb8 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingFetcher.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingFetcher.java @@ -39,6 +39,6 @@ public List fetchOfferings( if (strategy == null) { throw new MarketException(OfferingErrorCode.NOT_SUPPORTED_FILTER); } - return strategy.fetchOfferings(searchKeyword, lastId, pageable); + return strategy.fetch(searchKeyword, lastId, pageable); } } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java index 77ac05781..3d1d4b0b6 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java @@ -7,6 +7,7 @@ import com.zzang.chongdae.offering.domain.OfferingJoinedCount; import com.zzang.chongdae.offering.domain.OfferingMeeting; import com.zzang.chongdae.offering.domain.OfferingPrice; +import com.zzang.chongdae.offering.domain.OfferingStatus; import com.zzang.chongdae.offering.domain.UpdatedOffering; import com.zzang.chongdae.offering.exception.OfferingErrorCode; import com.zzang.chongdae.offering.repository.OfferingRepository; @@ -28,7 +29,9 @@ import com.zzang.chongdae.offeringmember.repository.OfferingMemberRepository; import com.zzang.chongdae.offeringmember.repository.entity.OfferingMemberEntity; import com.zzang.chongdae.storage.service.StorageService; +import java.time.Clock; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -47,6 +50,7 @@ public class OfferingService { private final StorageService storageService; private final ProductImageExtractor imageExtractor; private final OfferingFetcher offeringFetcher; + private final Clock clock; public OfferingDetailResponse getOfferingDetail(Long offeringId, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(offeringId) @@ -106,6 +110,7 @@ public OfferingMeetingResponse updateOfferingMeeting( OfferingEntity offering = offeringRepository.findById(offeringId) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); validateIsProposer(offering, member); + validateMeetingDate(request.meetingDate()); OfferingMeeting offeringMeeting = request.toOfferingMeeting(); offering.updateMeeting(offeringMeeting); return new OfferingMeetingResponse(offering.toOfferingMeeting()); @@ -120,7 +125,7 @@ private void validateIsProposer(OfferingEntity offering, MemberEntity member) { @WriterDatabase public Long saveOffering(OfferingSaveRequest request, MemberEntity member) { OfferingEntity offering = request.toEntity(member); - validateMeetingDate(offering); + validateMeetingDate(offering.getMeetingDate()); OfferingEntity savedOffering = offeringRepository.save(offering); OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, offering, OfferingMemberRole.PROPOSER); @@ -129,9 +134,10 @@ public Long saveOffering(OfferingSaveRequest request, MemberEntity member) { return savedOffering.getId(); } - private void validateMeetingDate(OfferingEntity offering) { - LocalDate thresholdDate = LocalDate.now().plusDays(1); - if (offering.getMeetingDate().toLocalDate().isBefore(thresholdDate)) { + private void validateMeetingDate(LocalDateTime offeringMeetingDateTime) { + LocalDate thresholdDate = LocalDate.now(clock); + LocalDate targetDate = offeringMeetingDateTime.toLocalDate(); + if (targetDate.isBefore(thresholdDate)) { throw new MarketException(OfferingErrorCode.CANNOT_MEETING_DATE_BEFORE_THAN_TOMORROW); } } @@ -151,10 +157,11 @@ public OfferingProductImageResponse extractProductImageFromOg(OfferingProductIma public OfferingUpdateResponse updateOffering(Long offeringId, OfferingUpdateRequest request, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(offeringId) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); - UpdatedOffering updatedOffering = request.toUpdatedOffering(); validateIsProposer(offering, member); + UpdatedOffering updatedOffering = request.toUpdatedOffering(); validateUpdatedTotalCount(offering.getCurrentCount(), updatedOffering.getOfferingPrice().getTotalCount()); offering.update(updatedOffering); + updateStatus(offering); return new OfferingUpdateResponse(offering, offering.toOfferingPrice(), offering.toOfferingJoinedCount()); } @@ -164,6 +171,16 @@ private void validateUpdatedTotalCount(Integer currentCount, Integer updatedTota } } + private void updateStatus(OfferingEntity offering) { // TODO : 도메인 분리 필요 + OfferingStatus offeringStatus = offering.toOfferingJoinedCount().decideOfferingStatus(); + LocalDate tomorrow = LocalDate.now(clock).plusDays(1); + LocalDate meetingDate = offering.getMeetingDate().toLocalDate(); + if (meetingDate.isBefore(tomorrow) || meetingDate.isEqual(tomorrow)) { + offeringStatus = OfferingStatus.IMMINENT; + } + offering.updateOfferingStatus(offeringStatus); + } + @WriterDatabase public void deleteOffering(Long offeringId, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(offeringId) diff --git a/backend/src/main/java/com/zzang/chongdae/scheduler/service/SchedulerService.java b/backend/src/main/java/com/zzang/chongdae/scheduler/service/SchedulerService.java index 7b6e1842d..38387450d 100644 --- a/backend/src/main/java/com/zzang/chongdae/scheduler/service/SchedulerService.java +++ b/backend/src/main/java/com/zzang/chongdae/scheduler/service/SchedulerService.java @@ -25,21 +25,20 @@ public class SchedulerService { public void run() { LocalDateTime today = LocalDate.now().atStartOfDay(); LocalDateTime tomorrow = today.plusDays(1); + LocalDateTime yesterday = today.minusDays(1); - updateStatusConfirmedDaily(today); + updateStatusConfirmedDaily(yesterday); updateStatusImminentDaily(tomorrow); } private void updateStatusConfirmedDaily(LocalDateTime meetingDate) { - List offerings - = offeringRepository.findByMeetingDateAndOfferingStatusNot(meetingDate, OfferingStatus.CONFIRMED); + List offerings = offeringRepository.findByMeetingDateAndOfferingStatusNotConfirmed(meetingDate); offerings.forEach(offering -> offering.updateOfferingStatus(OfferingStatus.CONFIRMED)); offerings.forEach(offering -> offering.updateRoomStatus(CommentRoomStatus.BUYING)); } private void updateStatusImminentDaily(LocalDateTime meetingDate) { - List offerings - = offeringRepository.findByMeetingDateAndOfferingStatusNot(meetingDate, OfferingStatus.CONFIRMED); + List offerings = offeringRepository.findByMeetingDateAndOfferingStatusNotConfirmed(meetingDate); offerings.forEach(offering -> offering.updateOfferingStatus(OfferingStatus.IMMINENT)); } } diff --git a/backend/src/main/resources/templates/index.html b/backend/src/main/resources/templates/index.html index 92b36b513..020320325 100644 --- a/backend/src/main/resources/templates/index.html +++ b/backend/src/main/resources/templates/index.html @@ -279,7 +279,7 @@ if (offering.status === 'IMMINENT') { statusClass = 'status-imminent'; statusName = '마감임박'; - } else if (offering.status === 'CLOSED') { + } else if (offering.status === 'CONFIRMED') { statusClass = 'status-closed'; statusName = '모집마감'; } else if (offering.status === 'FULL') { diff --git a/backend/src/test/java/com/zzang/chongdae/comment/service/CommentServiceTest.java b/backend/src/test/java/com/zzang/chongdae/comment/service/CommentServiceTest.java index d582fb2d5..511ebe5d5 100644 --- a/backend/src/test/java/com/zzang/chongdae/comment/service/CommentServiceTest.java +++ b/backend/src/test/java/com/zzang/chongdae/comment/service/CommentServiceTest.java @@ -1,8 +1,10 @@ package com.zzang.chongdae.comment.service; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import com.zzang.chongdae.comment.service.dto.CommentRoomAllResponse; +import com.zzang.chongdae.comment.service.dto.CommentRoomAllResponseItem; import com.zzang.chongdae.comment.service.dto.CommentRoomInfoResponse; import com.zzang.chongdae.global.service.ServiceTest; import com.zzang.chongdae.member.repository.entity.MemberEntity; @@ -24,13 +26,24 @@ public class CommentServiceTest extends ServiceTest { class GetAllCommentRoom { MemberEntity member; - OfferingEntity offering; + OfferingEntity firstOffering; + OfferingEntity secondOffering; + OfferingEntity thirdOffering; + OfferingEntity fourthOffering; @BeforeEach void setUp() { member = memberFixture.createMember("dora"); - offering = offeringFixture.createOffering(member); - offeringMemberFixture.createProposer(member, offering); + firstOffering = offeringFixture.createOffering(member); + secondOffering = offeringFixture.createOffering(member); + thirdOffering = offeringFixture.createOffering(member); + fourthOffering = offeringFixture.createOffering(member); + offeringMemberFixture.createProposer(member, firstOffering); + offeringMemberFixture.createProposer(member, secondOffering); + offeringMemberFixture.createProposer(member, thirdOffering); + offeringMemberFixture.createProposer(member, fourthOffering); + commentFixture.createComment(member, firstOffering); + commentFixture.createComment(member, secondOffering); } @DisplayName("로그인한 유저가 참여한 댓글방 목록을 조회할 수 있다") @@ -40,20 +53,32 @@ void should_getAllCommentRoom_when_givenLoginMember() { CommentRoomAllResponse response = commentService.getAllCommentRoom(member); // then - assertEquals(response.offerings().size(), 1); + assertEquals(response.offerings().size(), 4); + } + + @DisplayName("최근 댓글이 작성된 순으로 정렬해 댓글방 목록을 조회할 수 있다") + @Test + void should_getAllCommentRoomWithOrder_when_givenLoginMember() { + // when + CommentRoomAllResponse response = commentService.getAllCommentRoom(member); + + // then + assertThat(response.offerings()) + .extracting(CommentRoomAllResponseItem::offeringId) + .containsExactly(2L, 1L, 3L, 4L); } @DisplayName("댓글방 목록 조회 시 삭제된 공모에 대한 댓글방은 제목에 삭제되었다고 명시되어 있다") @Test void should_getAllCommentRoomWithDeletedCommentRoom_when_giveLoginMember() { // given - offeringFixture.deleteOffering(offering); + offeringFixture.deleteOffering(firstOffering); // when CommentRoomAllResponse response = commentService.getAllCommentRoom(member); // then - assertEquals(response.offerings().get(0).offeringTitle(), "삭제된 공동구매입니다."); + assertEquals(response.offerings().get(1).offeringTitle(), "삭제된 공동구매입니다."); } } diff --git a/backend/src/test/java/com/zzang/chongdae/global/service/ServiceTest.java b/backend/src/test/java/com/zzang/chongdae/global/service/ServiceTest.java index 1dc08d28c..0d5973e33 100644 --- a/backend/src/test/java/com/zzang/chongdae/global/service/ServiceTest.java +++ b/backend/src/test/java/com/zzang/chongdae/global/service/ServiceTest.java @@ -4,6 +4,7 @@ import com.zzang.chongdae.global.domain.DomainSupplier; import com.zzang.chongdae.global.helper.CookieProvider; import com.zzang.chongdae.global.helper.DatabaseCleaner; +import java.time.Clock; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -20,6 +21,9 @@ public abstract class ServiceTest extends DomainSupplier { @Autowired protected CookieProvider cookieProvider; + @Autowired + protected Clock clock; + @BeforeEach protected void setUp() { databaseCleaner.execute(); diff --git a/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java index d660abd9f..10c377e8b 100644 --- a/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java @@ -513,7 +513,7 @@ void should_createOffering_when_givenOfferingCreateRequest() { "서울특별시 광진구 구의강변로 3길 11", "상세주소아파트", "구의동", - LocalDateTime.now().plusDays(1), + LocalDateTime.now(clock).plusDays(1), "내용입니다." ); @@ -540,7 +540,7 @@ void should_createOffering_when_givenOfferingWithoutOriginPriceCreateRequest() { "서울특별시 광진구 구의강변로 3길 11", "상세주소아파트", "구의동", - LocalDateTime.now().plusDays(1), + LocalDateTime.now(clock).plusDays(1), "내용입니다." ); @@ -635,7 +635,7 @@ void should_throwException_when_overMaximumTotalCount() { .statusCode(400); } - @DisplayName("거래 날짜를 내일보다 과거로 설정하는 경우 예외가 발생한다.") + @DisplayName("거래 날짜를 오늘보다 과거로 설정하는 경우 예외가 발생한다.") @Test void should_throwException_when_meetingDateBeforeTomorrow() { OfferingSaveRequest request = new OfferingSaveRequest( @@ -648,7 +648,7 @@ void should_throwException_when_meetingDateBeforeTomorrow() { "서울특별시 광진구 구의강변로 3길 11", "상세주소아파트", "구의동", - LocalDateTime.now(), + LocalDateTime.now(clock).minusDays(1), "내용입니다." ); @@ -946,7 +946,7 @@ void should_throwException_when_updateTotalCountLessEqualThanCurrentCount() { .statusCode(400); } - @DisplayName("모집 날짜가 현재와 같거나 지날 경우 수정할 수 없다.") + @DisplayName("모집 날짜가 지날 경우 수정할 수 없다.") @Test void should_throwException_when_modifyMeetingDateBeforeNowToday() { OfferingUpdateRequest request = new OfferingUpdateRequest( @@ -959,7 +959,7 @@ void should_throwException_when_modifyMeetingDateBeforeNowToday() { "수정할 모집 장소 주소", "수정할 모집 상세 주소", "수정된동", - LocalDateTime.now(), + LocalDateTime.now().minusDays(1), "수정할 공모 상세 내용" ); diff --git a/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java b/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java index b776e9a34..98c472ec2 100644 --- a/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java @@ -336,7 +336,7 @@ void should_createOffering_when_givenOfferingWithoutOriginPriceCreateRequest() { "서울특별시 광진구 구의강변로 3길 11", "상세주소아파트", "구의동", - LocalDateTime.now().plusDays(1), + LocalDateTime.now(clock).plusDays(1), "내용입니다." ); Long expected = 1L;