diff --git a/android/app/build.gradle b/android/app/build.gradle
index c73909063..75e464690 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -20,8 +20,8 @@ android {
minSdk 28
targetSdk 33
- versionCode 12
- versionName "5.1.2"
+ versionCode 14
+ versionName "6.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -110,6 +110,9 @@ dependencies {
// app update manager
implementation 'com.google.android.play:app-update-ktx:2.1.0'
+
+ // photo view 확대 가능 이미지뷰 라이브러리
+ implementation 'com.github.chrisbanes:PhotoView:2.3.0'
}
kapt {
correctErrorTypes true
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f2cf40d13..4654d778b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -22,6 +22,9 @@
android:theme="@style/Theme.DdangDdangDdang"
android:usesCleartextTraffic="true"
tools:targetApi="31">
+
@@ -68,7 +71,9 @@
+ android:windowSoftInputMode="adjustPan"
+ android:taskAffinity=""
+ android:excludeFromRecents="true" />
diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png
index 68833f907..053662f80 100644
Binary files a/android/app/src/main/ic_launcher-playstore.png and b/android/app/src/main/ic_launcher-playstore.png differ
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/Annotations.kt b/android/app/src/main/java/com/ddangddangddang/android/di/Annotations.kt
index f45a1df38..88b541c15 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/di/Annotations.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/Annotations.kt
@@ -17,3 +17,7 @@ annotation class DateFormatter
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TimeFormatter
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DefaultDateTimeFormatter
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/FormatterModule.kt b/android/app/src/main/java/com/ddangddangddang/android/di/FormatterModule.kt
index 8cf7fbffb..5c75a685c 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/di/FormatterModule.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/FormatterModule.kt
@@ -33,4 +33,14 @@ object FormatterModule {
Locale.KOREAN,
)
}
+
+ @DefaultDateTimeFormatter
+ @Singleton
+ @Provides
+ fun provideDefaultDateTimeFormatter(@ApplicationContext context: Context): DateTimeFormatter {
+ return DateTimeFormatter.ofPattern(
+ context.getString(R.string.all_date_time_format),
+ Locale.KOREAN,
+ )
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt
index ddce2225b..bb5b93308 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt
@@ -45,3 +45,8 @@ fun Chip.onCloseClick(onCloseClick: () -> Unit) {
fun TextView.setTextOrEmpty(text: String?) {
this.text = text ?: ""
}
+
+@BindingAdapter("isSelected")
+fun View.isSelected(isSelected: Boolean) {
+ this.isSelected = isSelected
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorTypeHandler.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorTypeHandler.kt
index 0d1184d89..0f61ec127 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorTypeHandler.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorTypeHandler.kt
@@ -1,10 +1,50 @@
package com.ddangddangddang.android.feature.common
import android.app.Activity
+import android.view.View
import androidx.annotation.StringRes
+import androidx.fragment.app.Fragment
import com.ddangddangddang.android.util.view.Toaster
+import com.ddangddangddang.android.util.view.showSnackbar
fun Activity.notifyFailureMessage(errorType: ErrorType, @StringRes defaultMessageId: Int) {
val defaultMessage = getString(defaultMessageId)
Toaster.showShort(this, errorType.message ?: defaultMessage)
}
+
+fun Fragment.notifyFailureMessage(errorType: ErrorType, @StringRes defaultMessageId: Int) {
+ val defaultMessage = getString(defaultMessageId)
+ Toaster.showShort(requireContext(), errorType.message ?: defaultMessage)
+}
+
+fun Activity.notifyFailureSnackBar(
+ anchorView: View,
+ errorType: ErrorType,
+ @StringRes defaultMessageId: Int,
+ @StringRes actionMessageId: Int,
+ action: () -> Unit = {},
+) {
+ val defaultMessage = getString(defaultMessageId)
+ val actionMessage = getString(actionMessageId)
+ anchorView.showSnackbar(
+ message = errorType.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ action,
+ )
+}
+
+fun Fragment.notifyFailureSnackBar(
+ anchorView: View,
+ errorType: ErrorType,
+ @StringRes defaultMessageId: Int,
+ @StringRes actionMessageId: Int,
+ action: () -> Unit = {},
+) {
+ val defaultMessage = getString(defaultMessageId)
+ val actionMessage = getString(actionMessageId)
+ anchorView.showSnackbar(
+ message = errorType.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ action,
+ )
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/common/PriceTextWatcher.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/common/PriceTextWatcher.kt
new file mode 100644
index 000000000..b9ab4cc05
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/common/PriceTextWatcher.kt
@@ -0,0 +1,42 @@
+package com.ddangddangddang.android.feature.common
+
+import android.text.Editable
+import android.text.TextWatcher
+
+class PriceTextWatcher(private val onAfterChanged: (String) -> Unit) : TextWatcher {
+ private var cursorPositionFromEnd: Int = 0
+
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ s?.let { str ->
+ cursorPositionFromEnd = str.length - (start + count)
+ moveCursorIfOnlyCommaRemoved(str, before)
+ }
+ }
+
+ private fun moveCursorIfOnlyCommaRemoved(str: CharSequence, before: Int) {
+ val strOnlyNumber = str.filter { it.isDigit() }
+ val expectedCommaCount = (strOnlyNumber.length - 1) / 3
+
+ // 쉼표의 개수가 적고 지워진 문자가 1개인 경우 커서를 앞으로 1 움직입니다.
+ val actualCommaCount = str.count { it == ',' }
+ if (actualCommaCount < expectedCommaCount && before == 1) {
+ cursorPositionFromEnd++
+ }
+ }
+
+ override fun afterTextChanged(s: Editable?) {
+ s?.let { onAfterChanged(s.toString()) }
+ }
+
+ fun getCursorPosition(
+ textLength: Int,
+ defaultCursorPositionFromEnd: Int,
+ ): Int {
+ cursorPositionFromEnd =
+ if (cursorPositionFromEnd > 0) cursorPositionFromEnd else defaultCursorPositionFromEnd
+ val cursorPosition = textLength - cursorPositionFromEnd
+ return if (cursorPosition > 0) cursorPosition else 0
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt
index 182dc99fd..6a62cedbb 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt
@@ -12,8 +12,7 @@ import com.ddangddangddang.android.feature.detail.bid.AuctionBidDialog
import com.ddangddangddang.android.feature.imageDetail.ImageDetailActivity
import com.ddangddangddang.android.feature.messageRoom.MessageRoomActivity
import com.ddangddangddang.android.feature.report.ReportActivity
-import com.ddangddangddang.android.model.ReportType
-import com.ddangddangddang.android.notification.NotificationType
+import com.ddangddangddang.android.model.ReportInfo
import com.ddangddangddang.android.notification.cancelActiveNotification
import com.ddangddangddang.android.util.binding.BindingActivity
import com.ddangddangddang.android.util.view.Toaster
@@ -31,11 +30,18 @@ class AuctionDetailActivity :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.viewModel = viewModel
+ setupDetailView()
setupViewModel()
-
if (savedInstanceState == null) viewModel.loadAuctionDetail(auctionId)
}
+ private fun setupDetailView() {
+ binding.vpDetailInfo.adapter = DetailFragmentAdapter(supportFragmentManager, lifecycle)
+ TabLayoutMediator(binding.tbDetailInfo, binding.vpDetailInfo) { tab, position ->
+ tab.text = getString(DetailFragmentType.getTypeFrom(position).nameId)
+ }.attach()
+ }
+
private fun setupViewModel() {
observeLoadingWithDialog(
this,
@@ -91,7 +97,7 @@ class AuctionDetailActivity :
}
private fun navigateToReport(auctionId: Long) {
- startActivity(ReportActivity.getIntent(this, ReportType.ArticleReport.ordinal, auctionId))
+ startActivity(ReportActivity.getIntent(this, ReportInfo.ArticleReportInfo(auctionId)))
}
private fun navigateToImageDetail(images: List, focusPosition: Int) {
@@ -147,7 +153,7 @@ class AuctionDetailActivity :
}
private fun cancelNotification() {
- cancelActiveNotification(NotificationType.BID.name, auctionId.toInt())
+ cancelActiveNotification(auctionId.toInt())
}
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailBottomButtonStatus.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailBottomButtonStatus.kt
index 4922cb72f..3eeafb305 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailBottomButtonStatus.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailBottomButtonStatus.kt
@@ -17,6 +17,8 @@ enum class AuctionDetailBottomButtonStatus(
EnterAuctionChatRoom(R.string.detail_auction_chat_room_entrance, true),
MyAuction(R.string.detail_auction_my_auction, false),
+
+ AlreadyLastBidder(R.string.detail_auction_already_last_bidder, false),
;
companion object {
@@ -26,10 +28,18 @@ enum class AuctionDetailBottomButtonStatus(
val isOwner = auctionDetailModel.isOwner
val auctionStatus = auctionDetailModel.auctionDetailStatusModel
val chatStatus = auctionDetailModel.chatAuctionDetailModel
+ val isLastBidder = auctionDetailModel.isLastBidder
return when {
canEnterMessageRoom(chatStatus) -> EnterAuctionChatRoom
- canBidAuction(auctionStatus, isOwner) -> BidAuction
+ canBidAuction(auctionStatus, isOwner) -> {
+ if (isLastBidder) {
+ AlreadyLastBidder
+ } else {
+ BidAuction
+ }
+ }
+
isOwner -> MyAuction
else -> FinishAuction
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt
index 4a29086cc..cb9eff13c 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt
@@ -70,9 +70,8 @@ object AuctionDetailFormatter {
val minutes = (differenceInMills / (60 * 1000L)) % 60
return buildString {
- if (days > 0L) append("${days}일")
- if (hours > 0L) append(" ${hours}시간")
- if (minutes > 0L) append(" ${minutes}분")
+ if (days > 0L) append("${days}일 ")
+ append(" ${String.format("%02d:%02d", hours, minutes)}")
}.trim()
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt
index dba80674e..c656beed9 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt
@@ -80,8 +80,9 @@ class AuctionDetailViewModel @Inject constructor(
when (it) {
AuctionDetailBottomButtonStatus.BidAuction -> popupAuctionBidEvent()
AuctionDetailBottomButtonStatus.EnterAuctionChatRoom -> enterChatRoomEvent()
- AuctionDetailBottomButtonStatus.FinishAuction -> {}
+ AuctionDetailBottomButtonStatus.AlreadyLastBidder -> {}
AuctionDetailBottomButtonStatus.MyAuction -> {}
+ AuctionDetailBottomButtonStatus.FinishAuction -> {}
}
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/DetailFragmentAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/DetailFragmentAdapter.kt
new file mode 100644
index 000000000..2e86133fd
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/DetailFragmentAdapter.kt
@@ -0,0 +1,14 @@
+package com.ddangddangddang.android.feature.detail
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.Lifecycle
+import androidx.viewpager2.adapter.FragmentStateAdapter
+
+class DetailFragmentAdapter(fragmentManager: FragmentManager, lifeCycle: Lifecycle) :
+ FragmentStateAdapter(fragmentManager, lifeCycle) {
+ override fun getItemCount(): Int = DetailFragmentType.values().size
+ override fun createFragment(position: Int): Fragment {
+ return DetailFragmentType.getTypeFrom(position).create()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/DetailFragmentType.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/DetailFragmentType.kt
new file mode 100644
index 000000000..71c2fac3f
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/DetailFragmentType.kt
@@ -0,0 +1,36 @@
+package com.ddangddangddang.android.feature.detail
+
+import androidx.annotation.StringRes
+import androidx.fragment.app.Fragment
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.feature.detail.bidHistory.BidHistoryFragment
+import com.ddangddangddang.android.feature.detail.info.AuctionInfoFragment
+import com.ddangddangddang.android.feature.detail.qna.QnaFragment
+
+enum class DetailFragmentType(val tag: String, @StringRes val nameId: Int) {
+ AUCTION_INFO("auction_info", R.string.detail_auction_info_title) {
+ override fun create(): Fragment {
+ return AuctionInfoFragment()
+ }
+ },
+
+ QNA("qna_tag", R.string.detail_auction_qna_title) {
+ override fun create(): Fragment {
+ return QnaFragment()
+ }
+ },
+ BID_HISTORY("bid_history_tag", R.string.detail_auction_bid_history) {
+ override fun create(): Fragment {
+ return BidHistoryFragment()
+ }
+ },
+ ;
+
+ abstract fun create(): Fragment
+
+ companion object {
+ fun getTypeFrom(position: Int): DetailFragmentType {
+ return values()[position]
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt
index 2a40744e1..bdc3bb3e4 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt
@@ -1,13 +1,14 @@
package com.ddangddangddang.android.feature.detail.bid
+import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
-import android.text.Editable
-import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
@@ -15,7 +16,9 @@ import androidx.fragment.app.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.FragmentAuctionBidDialogBinding
import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.feature.common.PriceTextWatcher
import com.ddangddangddang.android.feature.detail.AuctionDetailViewModel
+import com.ddangddangddang.android.feature.register.RegisterAuctionViewModel
import com.ddangddangddang.android.util.view.Toaster
import dagger.hilt.android.AndroidEntryPoint
@@ -27,16 +30,7 @@ class AuctionBidDialog : DialogFragment() {
private val viewModel: AuctionBidViewModel by viewModels()
private val activityViewModel: AuctionDetailViewModel by activityViewModels()
-
- private val watcher = object : TextWatcher {
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
-
- override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
-
- override fun afterTextChanged(s: Editable?) {
- s?.let { viewModel.changeInputPriceText(s.toString()) }
- }
- }
+ private val bidPriceWatcher by lazy { PriceTextWatcher { viewModel.changeInputPriceText(it) } }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -62,6 +56,7 @@ class AuctionBidDialog : DialogFragment() {
super.onViewCreated(view, savedInstanceState)
dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ setupKeyboard()
setupListener()
setupObserver()
}
@@ -71,13 +66,25 @@ class AuctionBidDialog : DialogFragment() {
binding.etBidPrice.requestFocus()
}
- private fun setupListener() {
- binding.etBidPrice.addTextChangedListener(watcher)
- binding.etBidPrice.setOnClickListener {
- binding.etBidPrice.setSelection(getCursorPositionFrontSuffix(binding.etBidPrice.text.toString()))
+ private fun setupKeyboard() {
+ binding.etBidPrice.setOnEditorActionListener { _, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ hideKeyboard()
+ return@setOnEditorActionListener true
+ }
+ return@setOnEditorActionListener false
}
}
+ private fun hideKeyboard() {
+ val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(binding.etBidPrice.windowToken, 0)
+ }
+
+ private fun setupListener() {
+ binding.etBidPrice.addTextChangedListener(bidPriceWatcher)
+ }
+
private fun setupObserver() {
viewModel.event.observe(viewLifecycleOwner) { handleEvent(it) }
viewModel.bidPrice.observe(viewLifecycleOwner) { setInputBidPrice(it) }
@@ -115,15 +122,16 @@ class AuctionBidDialog : DialogFragment() {
}
private fun setInputBidPrice(price: Int) {
- val displayPrice = getString(R.string.detail_auction_bid_dialog_input_price, price)
- binding.etBidPrice.removeTextChangedListener(watcher)
+ val displayPrice = getString(R.string.all_price, price)
+ binding.etBidPrice.removeTextChangedListener(bidPriceWatcher)
binding.etBidPrice.setText(displayPrice)
- binding.etBidPrice.setSelection(getCursorPositionFrontSuffix(displayPrice)) // " 원" 앞으로 커서 이동
- binding.etBidPrice.addTextChangedListener(watcher)
- }
-
- private fun getCursorPositionFrontSuffix(content: String): Int {
- return content.length - AuctionBidViewModel.SUFFIX_INPUT_PRICE.length
+ binding.etBidPrice.setSelection(
+ bidPriceWatcher.getCursorPosition(
+ displayPrice.length,
+ RegisterAuctionViewModel.SUFFIX_INPUT_PRICE.length,
+ ),
+ ) // 이전 커서 위치로 이동
+ binding.etBidPrice.addTextChangedListener(bidPriceWatcher)
}
private fun showMessage(message: String) {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt
index ec288ec97..63f644d4f 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt
@@ -35,7 +35,7 @@ class AuctionBidViewModel @Inject constructor(
private fun convertStringPriceToInt(text: String): Int {
val originalValue = text.replace(",", "") // 문자열 내 들어있는 콤마를 모두 제거
- val priceValue = originalValue.substringBefore(SUFFIX_INPUT_PRICE) // " 원"
+ val priceValue = originalValue.substringBefore(SUFFIX_INPUT_PRICE.trim()).trim() // " 원"
val parsedValue =
priceValue.toBigIntegerOrNull() ?: return ZERO // 입력에 문자가 섞인 경우
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryAdapter.kt
new file mode 100644
index 000000000..9f584a0f3
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryAdapter.kt
@@ -0,0 +1,41 @@
+package com.ddangddangddang.android.feature.detail.bidHistory
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import com.ddangddangddang.android.model.BidHistoryModel
+import java.time.format.DateTimeFormatter
+
+class BidHistoryAdapter(
+ private val dateTimeFormatter: DateTimeFormatter,
+) : ListAdapter(BidHistoryDiffUtil) {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BidHistoryViewHolder {
+ return BidHistoryViewHolder.create(parent, dateTimeFormatter)
+ }
+
+ override fun onBindViewHolder(holder: BidHistoryViewHolder, position: Int) {
+ holder.bind(currentList[position])
+ }
+
+ fun setBidHistories(histories: List) {
+ submitList(histories)
+ }
+
+ companion object {
+ private val BidHistoryDiffUtil = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: BidHistoryModel,
+ newItem: BidHistoryModel,
+ ): Boolean {
+ return oldItem.price == newItem.price
+ }
+
+ override fun areContentsTheSame(
+ oldItem: BidHistoryModel,
+ newItem: BidHistoryModel,
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryFragment.kt
new file mode 100644
index 000000000..f94ac7345
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryFragment.kt
@@ -0,0 +1,69 @@
+package com.ddangddangddang.android.feature.detail.bidHistory
+
+import android.os.Bundle
+import android.view.View
+import android.widget.LinearLayout
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.recyclerview.widget.DividerItemDecoration
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.FragmentBidHistoryBinding
+import com.ddangddangddang.android.di.DefaultDateTimeFormatter
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
+import com.ddangddangddang.android.feature.detail.AuctionDetailViewModel
+import com.ddangddangddang.android.util.binding.BindingFragment
+import dagger.hilt.android.AndroidEntryPoint
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class BidHistoryFragment :
+ BindingFragment(R.layout.fragment_bid_history) {
+ private val activityViewModel: AuctionDetailViewModel by activityViewModels()
+ private val viewModel: BidHistoryViewModel by viewModels()
+
+ @Inject
+ @DefaultDateTimeFormatter
+ lateinit var dateTimeFormatter: DateTimeFormatter
+
+ private val bidHistoryAdapter: BidHistoryAdapter by lazy {
+ BidHistoryAdapter(dateTimeFormatter)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ setupAdapter()
+ setupViewModel()
+ }
+
+ private fun setupAdapter() {
+ with(binding.rvBidHistory) {
+ adapter = bidHistoryAdapter
+ addItemDecoration(DividerItemDecoration(requireContext(), LinearLayout.VERTICAL))
+ }
+ }
+
+ private fun setupViewModel() {
+ activityViewModel.auctionDetailModel.observe(viewLifecycleOwner) {
+ viewModel.loadBidHistory(it.id)
+ }
+ viewModel.histories.observe(viewLifecycleOwner) {
+ bidHistoryAdapter.setBidHistories(it)
+ }
+ viewModel.event.observe(viewLifecycleOwner) {
+ handleEvent(it)
+ }
+ }
+
+ private fun handleEvent(event: BidHistoryViewModel.Event) {
+ when (event) {
+ is BidHistoryViewModel.Event.BidHistoryLoadFailure -> {
+ requireActivity().notifyFailureMessage(
+ event.error,
+ R.string.detail_auction_bid_history_load_failure,
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryViewHolder.kt
new file mode 100644
index 000000000..67c1aca07
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryViewHolder.kt
@@ -0,0 +1,33 @@
+package com.ddangddangddang.android.feature.detail.bidHistory
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.ddangddangddang.android.databinding.ItemBidHistoryBinding
+import com.ddangddangddang.android.model.BidHistoryModel
+import java.time.format.DateTimeFormatter
+
+class BidHistoryViewHolder private constructor(
+ private val binding: ItemBidHistoryBinding,
+ dateTimeFormatter: DateTimeFormatter,
+) : RecyclerView.ViewHolder(binding.root) {
+
+ init {
+ binding.dateTimeFormatter = dateTimeFormatter
+ }
+
+ fun bind(item: BidHistoryModel) {
+ binding.item = item
+ }
+
+ companion object {
+ fun create(
+ parent: ViewGroup,
+ dateTimeFormatter: DateTimeFormatter,
+ ): BidHistoryViewHolder {
+ val layoutInflater = LayoutInflater.from(parent.context)
+ val binding = ItemBidHistoryBinding.inflate(layoutInflater, parent, false)
+ return BidHistoryViewHolder(binding, dateTimeFormatter)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryViewModel.kt
new file mode 100644
index 000000000..591a40bf5
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bidHistory/BidHistoryViewModel.kt
@@ -0,0 +1,59 @@
+package com.ddangddangddang.android.feature.detail.bidHistory
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.BidHistoryModel
+import com.ddangddangddang.android.model.mapper.AuctionBidHistoryModelMapper.toPresentation
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+
+@HiltViewModel
+class BidHistoryViewModel @Inject constructor(
+ val repository: AuctionRepository,
+) : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ private val _histories = MutableLiveData>(listOf())
+ val histories: LiveData>
+ get() = _histories
+
+ private val isLoading = AtomicBoolean(false)
+
+ fun loadBidHistory(auctionId: Long) {
+ if (isLoading.getAndSet(true)) return
+ viewModelScope.launch {
+ when (val response = repository.getBidHistories(auctionId)) {
+ is ApiResponse.Success -> {
+ _histories.value = response.body.map { it.toPresentation() }.reversed()
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = Event.BidHistoryLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = Event.BidHistoryLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = Event.BidHistoryLoadFailure(ErrorType.UNEXPECTED)
+ }
+ }
+ isLoading.set(false)
+ }
+ }
+
+ sealed class Event {
+ data class BidHistoryLoadFailure(val error: ErrorType) : Event()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaAdapter.kt
new file mode 100644
index 000000000..456daf4d0
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaAdapter.kt
@@ -0,0 +1,50 @@
+package com.ddangddangddang.android.feature.detail.qna
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import com.ddangddangddang.android.model.QnaModel
+
+class QnaAdapter(private val onClicks: OnClicks) :
+ ListAdapter(QnaDiffUtil) {
+
+ interface OnClicks {
+ fun onQuestionClick(questionId: Long)
+ fun onSubmitAnswerClick(questionId: Long)
+ fun onDeleteQuestionClick(questionId: Long)
+ fun onDeleteAnswerClick(answerId: Long)
+ fun onReportQuestionClick(questionId: Long)
+ fun onReportAnswerClick(questionId: Long, answerId: Long)
+ }
+
+ fun setQnas(list: List, callback: (() -> Unit)? = null) {
+ submitList(list, callback)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QnaViewHolder {
+ return QnaViewHolder.create(parent, onClicks)
+ }
+
+ override fun onBindViewHolder(holder: QnaViewHolder, position: Int) {
+ holder.bind(currentList[position])
+ }
+
+ companion object {
+ private val QnaDiffUtil =
+ object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: QnaModel.QuestionAndAnswerModel,
+ newItem: QnaModel.QuestionAndAnswerModel,
+ ): Boolean {
+ return oldItem.question.id == newItem.question.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: QnaModel.QuestionAndAnswerModel,
+ newItem: QnaModel.QuestionAndAnswerModel,
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaFragment.kt
new file mode 100644
index 000000000..29cd9be91
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaFragment.kt
@@ -0,0 +1,131 @@
+package com.ddangddangddang.android.feature.detail.qna
+
+import android.os.Bundle
+import android.view.View
+import android.widget.LinearLayout
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.recyclerview.widget.DividerItemDecoration
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.FragmentQnaBinding
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
+import com.ddangddangddang.android.feature.detail.AuctionDetailViewModel
+import com.ddangddangddang.android.feature.detail.qna.registeranswer.RegisterAnswerDialog
+import com.ddangddangddang.android.feature.detail.qna.registerquestion.RegisterQuestionDialog
+import com.ddangddangddang.android.feature.report.ReportActivity
+import com.ddangddangddang.android.model.ReportInfo
+import com.ddangddangddang.android.util.binding.BindingFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class QnaFragment : BindingFragment(R.layout.fragment_qna) {
+
+ private val viewModel: QnaViewModel by viewModels()
+ private val activityViewModel: AuctionDetailViewModel by activityViewModels()
+ private val onClicks = object : QnaAdapter.OnClicks {
+ override fun onQuestionClick(questionId: Long) {
+ viewModel.selectQna(questionId)
+ }
+
+ override fun onSubmitAnswerClick(questionId: Long) {
+ activityViewModel.auctionDetailModel.value?.let { model ->
+ RegisterAnswerDialog.show(
+ childFragmentManager,
+ model.id,
+ questionId,
+ )
+ }
+ }
+
+ override fun onDeleteQuestionClick(questionId: Long) {
+ viewModel.deleteQuestion(questionId)
+ }
+
+ override fun onDeleteAnswerClick(answerId: Long) {
+ viewModel.deleteAnswer(answerId)
+ }
+
+ override fun onReportQuestionClick(questionId: Long) {
+ viewModel.reportQuestion(questionId)
+ }
+
+ override fun onReportAnswerClick(questionId: Long, answerId: Long) {
+ viewModel.reportAnswer(questionId, answerId)
+ }
+ }
+ private val qnaAdapter = QnaAdapter(onClicks)
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ setupQnas()
+ setupViewModel()
+ setupBinding()
+ }
+
+ private fun setupQnas() {
+ binding.rvQna.adapter = qnaAdapter
+ binding.rvQna.addItemDecoration(DividerItemDecoration(context, LinearLayout.VERTICAL))
+ }
+
+ private fun setupViewModel() {
+ activityViewModel.auctionDetailModel.value?.let {
+ viewModel.initAuctionInfo(it.isOwner, it.id)
+ viewModel.loadQnas()
+ }
+
+ viewModel.qnas.observe(viewLifecycleOwner) {
+ qnaAdapter.setQnas(it)
+ }
+ viewModel.event.observe(viewLifecycleOwner) { event ->
+ handleEvent(event)
+ }
+ }
+
+ private fun handleEvent(event: QnaViewModel.QnaEvent) {
+ when (event) {
+ is QnaViewModel.QnaEvent.FailureLoadQnas -> {
+ notifyFailureMessage(event.errorType, R.string.detail_auction_qna_loading_failure)
+ }
+
+ is QnaViewModel.QnaEvent.FailureDeleteAnswer -> {
+ notifyFailureMessage(
+ event.errorType,
+ R.string.detail_auction_qna_answer_delete_failure,
+ )
+ }
+
+ is QnaViewModel.QnaEvent.FailureDeleteQuestion -> {
+ notifyFailureMessage(
+ event.errorType,
+ R.string.detail_auction_qna_question_delete_failure,
+ )
+ }
+
+ is QnaViewModel.QnaEvent.ReportQuestion -> {
+ navigateToReport(event.info)
+ }
+
+ is QnaViewModel.QnaEvent.ReportAnswer -> {
+ navigateToReport(event.info)
+ }
+ }
+ }
+
+ private fun navigateToReport(info: ReportInfo) {
+ startActivity(ReportActivity.getIntent(requireContext(), info))
+ }
+
+ private fun setupBinding() {
+ binding.clWriteQuestion.setOnClickListener {
+ activityViewModel.auctionDetailModel.value?.let { model ->
+ RegisterQuestionDialog.show(
+ childFragmentManager,
+ model.id,
+ )
+ }
+ }
+ activityViewModel.auctionDetailModel.value?.let {
+ binding.isOwner = it.isOwner
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaViewHolder.kt
new file mode 100644
index 000000000..5b69e077b
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaViewHolder.kt
@@ -0,0 +1,38 @@
+package com.ddangddangddang.android.feature.detail.qna
+
+import android.graphics.Typeface
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.content.res.ResourcesCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.ItemQnaBinding
+import com.ddangddangddang.android.model.QnaModel
+
+class QnaViewHolder private constructor(
+ private val binding: ItemQnaBinding,
+ onClicks: QnaAdapter.OnClicks,
+) : RecyclerView.ViewHolder(binding.root) {
+ init {
+ binding.onClicks = onClicks
+ }
+
+ fun bind(qna: QnaModel.QuestionAndAnswerModel) {
+ binding.model = qna
+ if (qna.isPicked) {
+ binding.tvQuestionTitle.setTextColor(binding.root.context.getColor(R.color.selected_second_region_text))
+ binding.tvQuestionTitle.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 700, false)
+ } else {
+ binding.tvQuestionTitle.setTextColor(binding.root.context.getColor(R.color.grey_700))
+ binding.tvQuestionTitle.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 400, false)
+ }
+ }
+
+ companion object {
+ fun create(parent: ViewGroup, onClicks: QnaAdapter.OnClicks): QnaViewHolder {
+ val layoutInflater = LayoutInflater.from(parent.context)
+ val binding = ItemQnaBinding.inflate(layoutInflater, parent, false)
+ return QnaViewHolder(binding, onClicks)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaViewModel.kt
new file mode 100644
index 000000000..78eacd58a
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/QnaViewModel.kt
@@ -0,0 +1,161 @@
+package com.ddangddangddang.android.feature.detail.qna
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.QnaModel
+import com.ddangddangddang.android.model.ReportInfo
+import com.ddangddangddang.android.model.mapper.QnaModelMapper.toPresentation
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+
+@HiltViewModel
+class QnaViewModel @Inject constructor(private val repository: AuctionRepository) : ViewModel() {
+
+ private val _qnas = MutableLiveData>()
+ val qnas: LiveData>
+ get() = _qnas
+
+ private val isLoading = AtomicBoolean(false)
+
+ private var isOwner: Boolean = false
+
+ private var auctionId: Long? = null
+
+ private var pickedQuestionId: Long? = null
+
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ fun initAuctionInfo(isOwner: Boolean, auctionId: Long) {
+ this.isOwner = isOwner
+ this.auctionId = auctionId
+ }
+
+ fun loadQnas() {
+ if (isLoading.getAndSet(true)) return
+ auctionId?.let { auctionId ->
+ viewModelScope.launch {
+ when (val response = repository.getAuctionQnas(auctionId)) {
+ is ApiResponse.Success ->
+ _qnas.value =
+ response.body.toPresentation(isOwner, pickedQuestionId).questionAndAnswers
+
+ is ApiResponse.Failure ->
+ _event.value =
+ QnaEvent.FailureLoadQnas(ErrorType.FAILURE(response.error))
+
+ is ApiResponse.NetworkError ->
+ _event.value =
+ QnaEvent.FailureLoadQnas(ErrorType.NETWORK_ERROR)
+
+ is ApiResponse.Unexpected ->
+ _event.value =
+ QnaEvent.FailureLoadQnas(ErrorType.UNEXPECTED)
+ }
+ isLoading.set(false)
+ }
+ }
+ }
+
+ fun selectQna(questionId: Long) {
+ _qnas.value?.let {
+ pickedQuestionId = questionId
+ _qnas.value = it.map { model ->
+ if (model.isPicked && model.question.id == questionId) {
+ pickedQuestionId = null
+ model.copy(isPicked = false)
+ } else {
+ model.copy(isPicked = model.question.id == questionId)
+ }
+ }
+ }
+ }
+
+ fun deleteQuestion(questionId: Long) {
+ if (isLoading.getAndSet(true)) return
+ viewModelScope.launch {
+ val response = repository.deleteQuestion(questionId)
+ isLoading.set(false)
+ when (response) {
+ is ApiResponse.Success -> {
+ pickedQuestionId = null
+ loadQnas()
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value =
+ QnaEvent.FailureDeleteQuestion(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ QnaEvent.FailureDeleteQuestion(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ QnaEvent.FailureDeleteQuestion(ErrorType.UNEXPECTED)
+ }
+ }
+ }
+ }
+
+ fun deleteAnswer(answerId: Long) {
+ if (isLoading.getAndSet(true)) return
+ viewModelScope.launch {
+ val response = repository.deleteAnswer(answerId)
+ isLoading.set(false)
+ when (response) {
+ is ApiResponse.Success -> {
+ loadQnas()
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value =
+ QnaEvent.FailureDeleteAnswer(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ QnaEvent.FailureDeleteAnswer(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ QnaEvent.FailureDeleteAnswer(ErrorType.UNEXPECTED)
+ }
+ }
+ }
+ }
+
+ fun reportQuestion(questionId: Long) {
+ auctionId?.let { auctionId ->
+ _event.value =
+ QnaEvent.ReportQuestion(ReportInfo.QuestionReportInfo(auctionId, questionId))
+ }
+ }
+
+ fun reportAnswer(questionId: Long, answerId: Long) {
+ auctionId?.let { auctionId ->
+ _event.value =
+ QnaEvent.ReportAnswer(ReportInfo.AnswerReportInfo(auctionId, questionId, answerId))
+ }
+ }
+
+ sealed class QnaEvent {
+ data class FailureLoadQnas(val errorType: ErrorType) : QnaEvent()
+ data class FailureDeleteQuestion(val errorType: ErrorType) : QnaEvent()
+ data class FailureDeleteAnswer(val errorType: ErrorType) : QnaEvent()
+ data class ReportQuestion(val info: ReportInfo.QuestionReportInfo) : QnaEvent()
+ data class ReportAnswer(val info: ReportInfo.AnswerReportInfo) : QnaEvent()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registeranswer/RegisterAnswerDialog.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registeranswer/RegisterAnswerDialog.kt
new file mode 100644
index 000000000..87f55578f
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registeranswer/RegisterAnswerDialog.kt
@@ -0,0 +1,118 @@
+package com.ddangddangddang.android.feature.detail.qna.registeranswer
+
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.viewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.FragmentRegisterAnswerDialogBinding
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
+import com.ddangddangddang.android.feature.detail.qna.QnaViewModel
+import com.ddangddangddang.android.util.view.Toaster
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class RegisterAnswerDialog : DialogFragment() {
+ private var _binding: FragmentRegisterAnswerDialogBinding? = null
+ private val binding: FragmentRegisterAnswerDialogBinding
+ get() = _binding!!
+
+ private val viewModel: RegisterAnswerViewModel by viewModels()
+ private val parentViewModel: QnaViewModel by viewModels(ownerProducer = { requireParentFragment() })
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ arguments?.let {
+ viewModel.initIds(it.getLong(AUCTION_ID_KEY), it.getLong(QUESTION_ID_KEY))
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentRegisterAnswerDialogBinding.inflate(inflater, container, false)
+ binding.lifecycleOwner = viewLifecycleOwner
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ setupHeight()
+ setupViewModel()
+ }
+
+ private fun setupHeight() {
+ val screenHeight = requireContext().resources?.displayMetrics?.heightPixels ?: return
+
+ val params: ViewGroup.LayoutParams? = binding.etAnswerContents.layoutParams
+ val height = params?.height ?: return
+ val newHeight = (screenHeight * 0.35).toInt()
+ if (newHeight > height) {
+ params.height = newHeight
+ binding.etAnswerContents.layoutParams = params
+ }
+ }
+
+ private fun setupViewModel() {
+ binding.viewModel = viewModel
+ viewModel.event.observe(viewLifecycleOwner) {
+ handleEvent(it)
+ }
+ }
+
+ private fun handleEvent(event: RegisterAnswerViewModel.WriteAnswerEvent) {
+ when (event) {
+ RegisterAnswerViewModel.WriteAnswerEvent.Cancel -> {
+ dismiss()
+ }
+
+ is RegisterAnswerViewModel.WriteAnswerEvent.FailureSubmitAnswer -> {
+ notifyFailureMessage(
+ event.errorType,
+ R.string.detail_auction_qna_answer_register_failure,
+ )
+ }
+
+ RegisterAnswerViewModel.WriteAnswerEvent.SubmitAnswer -> {
+ notifySuccessMessage()
+ parentViewModel.loadQnas()
+ dismiss()
+ }
+ }
+ }
+
+ private fun notifySuccessMessage() {
+ Toaster.showShort(
+ requireContext(),
+ getString(R.string.detail_auction_qna_answer_register_success),
+ )
+ }
+
+ companion object {
+ private const val WRITE_ANSWER_TAG = "write_answer_tag"
+ private const val AUCTION_ID_KEY = "auction_id"
+ private const val QUESTION_ID_KEY = "question_id"
+
+ fun show(
+ fragmentManager: FragmentManager,
+ auctionId: Long,
+ questionId: Long,
+ ) {
+ val dialog = RegisterAnswerDialog()
+ dialog.arguments =
+ Bundle().apply {
+ putLong(AUCTION_ID_KEY, auctionId)
+ putLong(QUESTION_ID_KEY, questionId)
+ }
+ dialog.show(fragmentManager, WRITE_ANSWER_TAG)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registeranswer/RegisterAnswerViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registeranswer/RegisterAnswerViewModel.kt
new file mode 100644
index 000000000..7d3c3d7f9
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registeranswer/RegisterAnswerViewModel.kt
@@ -0,0 +1,71 @@
+package com.ddangddangddang.android.feature.detail.qna.registeranswer
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.request.RegisterAnswerRequest
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+
+@HiltViewModel
+class RegisterAnswerViewModel @Inject constructor(private val repository: AuctionRepository) :
+ ViewModel() {
+
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ val content = MutableLiveData("")
+
+ private val isLoading = AtomicBoolean(false)
+ private var auctionId: Long = -1L
+ private var questionId: Long = -1L
+
+ fun initIds(auctionId: Long, questionId: Long) {
+ this.auctionId = auctionId
+ this.questionId = questionId
+ }
+
+ fun cancel() {
+ _event.value = WriteAnswerEvent.Cancel
+ }
+
+ fun submit() {
+ if (auctionId == -1L || questionId == -1L) return
+ if (isLoading.getAndSet(true)) return
+ viewModelScope.launch {
+ val request = RegisterAnswerRequest(auctionId, content.value ?: "")
+ when (val response = repository.registerAnswer(questionId, request)) {
+ is ApiResponse.Success -> _event.value = WriteAnswerEvent.SubmitAnswer
+ is ApiResponse.Failure -> {
+ _event.value =
+ WriteAnswerEvent.FailureSubmitAnswer(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ WriteAnswerEvent.FailureSubmitAnswer(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ WriteAnswerEvent.FailureSubmitAnswer(ErrorType.UNEXPECTED)
+ }
+ }
+ isLoading.set(false)
+ }
+ }
+
+ sealed class WriteAnswerEvent {
+ object Cancel : WriteAnswerEvent()
+ object SubmitAnswer : WriteAnswerEvent()
+ data class FailureSubmitAnswer(val errorType: ErrorType) : WriteAnswerEvent()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registerquestion/RegisterQuestionDialog.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registerquestion/RegisterQuestionDialog.kt
new file mode 100644
index 000000000..c77743074
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registerquestion/RegisterQuestionDialog.kt
@@ -0,0 +1,111 @@
+package com.ddangddangddang.android.feature.detail.qna.registerquestion
+
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.viewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.FragmentRegisterQuestionDialogBinding
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
+import com.ddangddangddang.android.feature.detail.qna.QnaViewModel
+import com.ddangddangddang.android.util.view.Toaster
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class RegisterQuestionDialog : DialogFragment() {
+ private var _binding: FragmentRegisterQuestionDialogBinding? = null
+ private val binding: FragmentRegisterQuestionDialogBinding
+ get() = _binding!!
+
+ private val viewModel: RegisterQuestionViewModel by viewModels()
+ private val parentViewModel: QnaViewModel by viewModels(ownerProducer = { requireParentFragment() })
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ arguments?.let {
+ viewModel.initAuctionId(it.getLong(AUCTION_ID_KEY))
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentRegisterQuestionDialogBinding.inflate(inflater, container, false)
+ binding.lifecycleOwner = viewLifecycleOwner
+ return binding.root
+ }
+
+ private fun notifySuccessMessage() {
+ Toaster.showShort(
+ requireContext(),
+ getString(R.string.detail_auction_qna_question_register_success),
+ )
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ setupHeight()
+ setupViewModel()
+ }
+
+ private fun setupHeight() {
+ val screenHeight = requireContext().resources?.displayMetrics?.heightPixels ?: return
+
+ val params: ViewGroup.LayoutParams? = binding.etQuestionContents.layoutParams
+ val height = params?.height ?: return
+ val newHeight = (screenHeight * 0.35).toInt()
+ if (newHeight > height) {
+ params.height = newHeight
+ binding.etQuestionContents.layoutParams = params
+ }
+ }
+
+ private fun setupViewModel() {
+ binding.viewModel = viewModel
+ viewModel.event.observe(viewLifecycleOwner) {
+ handleEvent(it)
+ }
+ }
+
+ private fun handleEvent(event: RegisterQuestionViewModel.WriteQuestionEvent) {
+ when (event) {
+ RegisterQuestionViewModel.WriteQuestionEvent.Cancel -> {
+ dismiss()
+ }
+
+ is RegisterQuestionViewModel.WriteQuestionEvent.FailureSubmitQuestion -> {
+ notifyFailureMessage(
+ event.errorType,
+ R.string.detail_auction_qna_question_register_failure,
+ )
+ }
+
+ RegisterQuestionViewModel.WriteQuestionEvent.SubmitQuestion -> {
+ notifySuccessMessage()
+ parentViewModel.loadQnas()
+ dismiss()
+ }
+ }
+ }
+
+ companion object {
+ private const val WRITE_QUESTION_TAG = "write_question_tag"
+ private const val AUCTION_ID_KEY = "auction_id"
+
+ fun show(fragmentManager: FragmentManager, auctionId: Long) {
+ val dialog = RegisterQuestionDialog()
+ dialog.arguments = Bundle().apply {
+ putLong(AUCTION_ID_KEY, auctionId)
+ }
+ dialog.show(fragmentManager, WRITE_QUESTION_TAG)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registerquestion/RegisterQuestionViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registerquestion/RegisterQuestionViewModel.kt
new file mode 100644
index 000000000..a7d5f0744
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/qna/registerquestion/RegisterQuestionViewModel.kt
@@ -0,0 +1,66 @@
+package com.ddangddangddang.android.feature.detail.qna.registerquestion
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.request.RegisterQuestionRequest
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+
+@HiltViewModel
+class RegisterQuestionViewModel @Inject constructor(private val repository: AuctionRepository) : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ val content = MutableLiveData("")
+
+ private val isLoading = AtomicBoolean(false)
+ private var auctionId: Long? = null
+
+ fun initAuctionId(auctionId: Long) {
+ this.auctionId = auctionId
+ }
+
+ fun cancel() {
+ _event.value = WriteQuestionEvent.Cancel
+ }
+
+ fun submit() {
+ if (isLoading.getAndSet(true) || auctionId == null) return
+ viewModelScope.launch {
+ auctionId?.let { id ->
+ when (val response = repository.registerQuestion(RegisterQuestionRequest(id, content.value ?: ""))) {
+ is ApiResponse.Success ->
+ _event.value = WriteQuestionEvent.SubmitQuestion
+
+ is ApiResponse.Failure ->
+ _event.value =
+ WriteQuestionEvent.FailureSubmitQuestion(ErrorType.FAILURE(response.error))
+
+ is ApiResponse.NetworkError ->
+ _event.value =
+ WriteQuestionEvent.FailureSubmitQuestion(ErrorType.NETWORK_ERROR)
+
+ is ApiResponse.Unexpected ->
+ _event.value =
+ WriteQuestionEvent.FailureSubmitQuestion(ErrorType.UNEXPECTED)
+ }
+ isLoading.set(false)
+ }
+ }
+ }
+
+ sealed class WriteQuestionEvent {
+ object Cancel : WriteQuestionEvent()
+ object SubmitQuestion : WriteQuestionEvent()
+ data class FailureSubmitQuestion(val errorType: ErrorType) : WriteQuestionEvent()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt
index fd884a87a..e12bab607 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt
@@ -88,6 +88,7 @@ class HomeFragment : BindingFragment(R.layout.fragment_home
private fun setupAuctionRecyclerView() {
with(binding.rvAuction) {
adapter = auctionAdapter
+ setHasFixedSize(true)
val space = resources.getDimensionPixelSize(R.dimen.margin_side_layout)
addItemDecoration(AuctionSpaceItemDecoration(spanCount = 2, space = space))
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt
index 4ea9824df..39f320b1d 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt
@@ -1,5 +1,6 @@
package com.ddangddangddang.android.feature.login
+import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
@@ -8,6 +9,7 @@ import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityLoginBinding
import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.feature.main.MainActivity
+import com.ddangddangddang.android.feature.onboarding.OnBoardingActivity
import com.ddangddangddang.android.global.AnalyticsDelegate
import com.ddangddangddang.android.global.AnalyticsDelegateImpl
import com.ddangddangddang.android.util.binding.BindingActivity
@@ -38,6 +40,7 @@ class LoginActivity :
is LoginViewModel.LoginEvent.KakaoLoginEvent -> loginByKakao()
is LoginViewModel.LoginEvent.CompleteLoginEvent -> navigateToMain()
is LoginViewModel.LoginEvent.FailureLoginEvent -> notifyLoginFailed(it.type)
+ LoginViewModel.LoginEvent.SignUpEvent -> navigateToOnBoarding()
}
}
}
@@ -84,7 +87,12 @@ class LoginActivity :
}
private fun navigateToMain() {
- startActivity(Intent(this, MainActivity::class.java))
+ startActivity(MainActivity.getIntent(this))
+ finish()
+ }
+
+ private fun navigateToOnBoarding() {
+ startActivity(OnBoardingActivity.getIntent(this))
finish()
}
@@ -96,4 +104,10 @@ class LoginActivity :
actionMessage = actionMessage,
)
}
+
+ companion object {
+ fun getIntent(context: Context): Intent {
+ return Intent(context, LoginActivity::class.java)
+ }
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt
index 728a4a9ce..adab84590 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt
@@ -28,22 +28,38 @@ class LoginViewModel @Inject constructor(
viewModelScope.launch {
val deviceToken = repository.getDeviceToken()
if (deviceToken.isNullOrBlank()) {
- LoginEvent.FailureLoginEvent(ErrorType.UNEXPECTED)
+ _event.value = LoginEvent.FailureLoginEvent(ErrorType.UNEXPECTED)
return@launch
}
val request = KakaoLoginRequest(accessToken, deviceToken)
when (val response = repository.loginByKakao(request)) {
- is ApiResponse.Success -> _event.value = LoginEvent.CompleteLoginEvent
- is ApiResponse.Failure -> _event.value = LoginEvent.FailureLoginEvent(ErrorType.FAILURE(response.error))
- is ApiResponse.NetworkError -> _event.value = LoginEvent.FailureLoginEvent(ErrorType.NETWORK_ERROR)
- is ApiResponse.Unexpected -> _event.value = LoginEvent.FailureLoginEvent(ErrorType.UNEXPECTED)
+ is ApiResponse.Success -> {
+ if (response.body.isSignUpUser) {
+ _event.value = LoginEvent.SignUpEvent
+ } else {
+ _event.value = LoginEvent.CompleteLoginEvent
+ }
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = LoginEvent.FailureLoginEvent(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = LoginEvent.FailureLoginEvent(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = LoginEvent.FailureLoginEvent(ErrorType.UNEXPECTED)
+ }
}
}
}
sealed class LoginEvent {
object KakaoLoginEvent : LoginEvent()
+ object SignUpEvent : LoginEvent()
object CompleteLoginEvent : LoginEvent()
data class FailureLoginEvent(val type: ErrorType) : LoginEvent()
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/FragmentType.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/FragmentType.kt
deleted file mode 100644
index 122e1e31e..000000000
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/main/FragmentType.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.ddangddangddang.android.feature.main
-
-enum class FragmentType(val tag: String) {
- HOME("fragment_home_tag"),
- SEARCH("fragment_search_tag"),
- MESSAGE("fragment_message_tag"),
- MY_PAGE("fragment_my_page_tag"),
-}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt
index dac8e77a6..a79fd8bee 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt
@@ -1,6 +1,7 @@
package com.ddangddangddang.android.feature.main
import android.Manifest
+import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
@@ -22,6 +23,7 @@ import com.ddangddangddang.android.feature.mypage.MyPageFragment
import com.ddangddangddang.android.feature.search.SearchFragment
import com.ddangddangddang.android.global.screenViewLogEvent
import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.compat.getSerializableExtraCompat
import com.ddangddangddang.android.util.view.BackKeyHandler
import com.ddangddangddang.android.util.view.showDialog
import com.ddangddangddang.android.util.view.showSnackbar
@@ -61,7 +63,7 @@ class MainActivity : BindingActivity(R.layout.activity_main
binding.viewModel = viewModel
setupViewModel()
- askNotificationPermission()
+ setupFragment()
onBackPressedDispatcher.addCallback(this, callback)
}
@@ -85,16 +87,17 @@ class MainActivity : BindingActivity(R.layout.activity_main
private fun handleEvent(event: MainViewModel.MainEvent) {
when (event) {
MainViewModel.MainEvent.HomeToTop -> scrollHomeToTop()
+ MainViewModel.MainEvent.NotificationPermissionCheck -> askNotificationPermission()
}
}
private fun scrollHomeToTop() {
val homeFragment =
- supportFragmentManager.findFragmentByTag(FragmentType.HOME.tag) as? HomeFragment
+ supportFragmentManager.findFragmentByTag(MainFragmentType.HOME.tag) as? HomeFragment
homeFragment?.scrollToTop()
}
- private fun changeFragment(type: FragmentType) {
+ private fun changeFragment(type: MainFragmentType) {
supportFragmentManager.commit {
setReorderingAllowed(true)
@@ -108,12 +111,12 @@ class MainActivity : BindingActivity(R.layout.activity_main
}
}
- private fun createFragment(type: FragmentType): Fragment {
+ private fun createFragment(type: MainFragmentType): Fragment {
return when (type) {
- FragmentType.HOME -> HomeFragment()
- FragmentType.SEARCH -> SearchFragment()
- FragmentType.MESSAGE -> MessageFragment()
- FragmentType.MY_PAGE -> MyPageFragment()
+ MainFragmentType.HOME -> HomeFragment()
+ MainFragmentType.SEARCH -> SearchFragment()
+ MainFragmentType.MESSAGE -> MessageFragment()
+ MainFragmentType.MY_PAGE -> MyPageFragment()
}
}
@@ -164,4 +167,23 @@ class MainActivity : BindingActivity(R.layout.activity_main
intent.putExtra(EXTRA_APP_PACKAGE, this.packageName)
startActivity(intent)
}
+
+ private fun setupFragment() {
+ if (viewModel.currentFragmentType.value == null) {
+ val type =
+ intent.getSerializableExtraCompat(KEY_MAIN_FRAGMENT_TYPE) ?: MainFragmentType.HOME
+ binding.bnvNavigation.selectedItemId = type.id
+ viewModel.setupFragmentType(type)
+ }
+ }
+
+ companion object {
+ private const val KEY_MAIN_FRAGMENT_TYPE = "main_fragment_type"
+
+ fun getIntent(context: Context, type: MainFragmentType = MainFragmentType.HOME): Intent {
+ val intent = Intent(context, MainActivity::class.java)
+ intent.putExtra(KEY_MAIN_FRAGMENT_TYPE, type)
+ return intent
+ }
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt
index 74250ea8d..99930d0c5 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt
@@ -1,21 +1,14 @@
package com.ddangddangddang.android.feature.main
import androidx.databinding.BindingAdapter
-import com.ddangddangddang.android.R
import com.google.android.material.bottomnavigation.BottomNavigationView
@BindingAdapter("onNavigationItemSelected")
fun BottomNavigationView.bindOnNavigationItemSelectedListener(
- onFragmentChange: (FragmentType) -> Unit,
+ onFragmentChange: (MainFragmentType) -> Unit,
) {
this.setOnItemSelectedListener { menuItem ->
- val fragmentType = when (menuItem.itemId) {
- R.id.menu_item_home -> FragmentType.HOME
- R.id.menu_item_search -> FragmentType.SEARCH
- R.id.menu_item_message -> FragmentType.MESSAGE
- R.id.menu_item_my_page -> FragmentType.MY_PAGE
- else -> throw IllegalArgumentException("Not found menu item")
- }
+ val fragmentType = MainFragmentType.of(menuItem.itemId)
onFragmentChange(fragmentType)
true
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainFragmentType.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainFragmentType.kt
new file mode 100644
index 000000000..22de2595c
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainFragmentType.kt
@@ -0,0 +1,18 @@
+package com.ddangddangddang.android.feature.main
+
+import androidx.annotation.IdRes
+import com.ddangddangddang.android.R
+
+enum class MainFragmentType(@IdRes val id: Int, val tag: String) {
+ HOME(R.id.menu_item_home, "fragment_home_tag"),
+ SEARCH(R.id.menu_item_search, "fragment_search_tag"),
+ MESSAGE(R.id.menu_item_message, "fragment_message_tag"),
+ MY_PAGE(R.id.menu_item_my_page, "fragment_my_page_tag"), ;
+
+ companion object {
+ fun of(@IdRes menuId: Int): MainFragmentType {
+ return values().firstOrNull { it.id == menuId }
+ ?: throw IllegalArgumentException("No match type for menu item")
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt
index e07cc8cf3..d309ff92c 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt
@@ -9,21 +9,28 @@ import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel() {
- private val _currentFragmentType: MutableLiveData =
- MutableLiveData(FragmentType.HOME)
- val currentFragmentType: LiveData
+ private val _currentFragmentType: MutableLiveData = MutableLiveData()
+ val currentFragmentType: LiveData
get() = _currentFragmentType
private val _event: SingleLiveEvent = SingleLiveEvent()
val event: LiveData
get() = _event
- val fragmentChange = { fragmentType: FragmentType ->
+ val fragmentChange = { fragmentType: MainFragmentType ->
changeCurrentFragmentType(fragmentType)
}
- private fun changeCurrentFragmentType(fragmentType: FragmentType) {
+ init {
+ _event.value = MainEvent.NotificationPermissionCheck
+ }
+
+ fun setupFragmentType(type: MainFragmentType) {
+ _currentFragmentType.value = type
+ }
+
+ private fun changeCurrentFragmentType(fragmentType: MainFragmentType) {
if (currentFragmentType.value == fragmentType) {
- if (fragmentType == FragmentType.HOME) {
+ if (fragmentType == MainFragmentType.HOME) {
_event.value = MainEvent.HomeToTop
}
}
@@ -33,5 +40,6 @@ class MainViewModel @Inject constructor() : ViewModel() {
sealed class MainEvent {
object HomeToTop : MainEvent()
+ object NotificationPermissionCheck : MainEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt
index 2b4744688..946f5cc3e 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt
@@ -9,6 +9,7 @@ import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.FragmentMessageBinding
import com.ddangddangddang.android.feature.common.notifyFailureMessage
import com.ddangddangddang.android.feature.messageRoom.MessageRoomActivity
+import com.ddangddangddang.android.reciever.MessageReceiver
import com.ddangddangddang.android.util.binding.BindingFragment
import dagger.hilt.android.AndroidEntryPoint
@@ -19,17 +20,39 @@ class MessageFragment : BindingFragment(R.layout.fragmen
viewModel.navigateToMessageRoom(roomId)
}
+ private val messageReceiver: MessageReceiver by lazy {
+ MessageReceiver { viewModel.loadMessageRooms() } // 필터링 없이 모든 수신을 받아서 처리 한다.
+ }
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupViewModel()
setupMessageRoomsRecyclerView()
binding.viewModel = viewModel
- if (viewModel.messageRooms.value == null) viewModel.loadMessageRooms()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (isHidden.not()) {
+ requireContext().registerReceiver(messageReceiver, MessageReceiver.getIntentFilter())
+ viewModel.loadMessageRooms() // 홈 키에서 돌아올 때, 메시지 방에서 돌아올 때 갱신 되도록 하기 위해 여기 배치
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ unregisterMessageReceiver()
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
- if (hidden.not()) viewModel.loadMessageRooms()
+ if (hidden) return unregisterMessageReceiver()
+ requireContext().registerReceiver(messageReceiver, MessageReceiver.getIntentFilter())
+ viewModel.loadMessageRooms()
+ }
+
+ private fun unregisterMessageReceiver() {
+ runCatching { requireContext().unregisterReceiver(messageReceiver) }
}
private fun setupViewModel() {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt
index a1fd78c6f..e914b386d 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt
@@ -12,6 +12,7 @@ import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.ChatRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@HiltViewModel
@@ -26,7 +27,10 @@ class MessageViewModel @Inject constructor(
val messageRooms: LiveData>
get() = _messageRooms
+ private var isLoading: AtomicBoolean = AtomicBoolean(false)
+
fun loadMessageRooms() {
+ if (isLoading.getAndSet(true)) return
viewModelScope.launch {
when (val response = repository.getChatRoomPreviews()) {
is ApiResponse.Success -> {
@@ -46,6 +50,7 @@ class MessageViewModel @Inject constructor(
_event.value = MessageEvent.MessageLoadFailure(ErrorType.UNEXPECTED)
}
}
+ isLoading.set(false)
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt
index 4975dfd7d..3449f4681 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt
@@ -3,16 +3,11 @@ package com.ddangddangddang.android.feature.messageRoom
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
-import com.ddangddangddang.android.di.DateFormatter
-import com.ddangddangddang.android.di.TimeFormatter
-import dagger.hilt.android.scopes.ActivityRetainedScoped
import java.time.format.DateTimeFormatter
-import javax.inject.Inject
-@ActivityRetainedScoped
-class MessageAdapter @Inject constructor(
- @DateFormatter private val dateFormatter: DateTimeFormatter,
- @TimeFormatter private val timeFormatter: DateTimeFormatter,
+class MessageAdapter(
+ private val dateFormatter: DateTimeFormatter,
+ private val timeFormatter: DateTimeFormatter,
) : ListAdapter(MessageDiffUtil) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
return MessageViewHolder.of(
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt
index f5a916677..5be8dbb3c 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt
@@ -8,19 +8,22 @@ import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityMessageRoomBinding
+import com.ddangddangddang.android.di.DateFormatter
+import com.ddangddangddang.android.di.TimeFormatter
import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
import com.ddangddangddang.android.feature.messageRoom.review.UserReviewDialog
import com.ddangddangddang.android.feature.report.ReportActivity
import com.ddangddangddang.android.global.AnalyticsDelegate
import com.ddangddangddang.android.global.AnalyticsDelegateImpl
import com.ddangddangddang.android.global.DdangDdangDdang
-import com.ddangddangddang.android.model.ReportType
-import com.ddangddangddang.android.notification.NotificationType
+import com.ddangddangddang.android.model.ReportInfo
import com.ddangddangddang.android.notification.cancelActiveNotification
+import com.ddangddangddang.android.notification.type.MessageType
import com.ddangddangddang.android.reciever.MessageReceiver
import com.ddangddangddang.android.util.binding.BindingActivity
import com.ddangddangddang.android.util.view.showSnackbar
import dagger.hilt.android.AndroidEntryPoint
+import java.time.format.DateTimeFormatter
import javax.inject.Inject
@AndroidEntryPoint
@@ -31,7 +34,16 @@ class MessageRoomActivity :
private val roomCreatedNotifyAdapter by lazy { RoomCreatedNotifyAdapter() }
@Inject
- lateinit var messageAdapter: MessageAdapter
+ @DateFormatter
+ lateinit var dateFormatter: DateTimeFormatter
+
+ @Inject
+ @TimeFormatter
+ lateinit var timeFormatter: DateTimeFormatter
+
+ private val messageAdapter: MessageAdapter by lazy {
+ MessageAdapter(dateFormatter, timeFormatter)
+ }
private val adapter by lazy { ConcatAdapter(roomCreatedNotifyAdapter, messageAdapter) }
@@ -83,7 +95,12 @@ class MessageRoomActivity :
}
private fun navigateToReport(roomId: Long) {
- startActivity(ReportActivity.getIntent(this, ReportType.MessageRoomReport.ordinal, roomId))
+ startActivity(
+ ReportActivity.getIntent(
+ this,
+ ReportInfo.MessageRoomReportInfo(roomId),
+ ),
+ )
}
private fun showUserRate() {
@@ -134,7 +151,7 @@ class MessageRoomActivity :
}
private fun cancelNotification() {
- cancelActiveNotification(NotificationType.MESSAGE.name, roomId.toInt())
+ cancelActiveNotification(MessageType.tag, roomId.toInt())
}
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt
index b82764f27..851ad7929 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt
@@ -15,6 +15,7 @@ import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.ChatRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@HiltViewModel
@@ -37,7 +38,8 @@ class MessageRoomViewModel @Inject constructor(
private val lastMessageId: Long?
get() = _messages.value?.lastOrNull()?.id
- private var isMessageLoading: Boolean = false
+ private val isMessageLoading = AtomicBoolean(false)
+ private val isSubmitLoading = AtomicBoolean(false)
fun loadMessageRoom(roomId: Long) {
viewModelScope.launch {
@@ -66,9 +68,8 @@ class MessageRoomViewModel @Inject constructor(
fun loadMessages() {
_messageRoomInfo.value?.let {
- if (isMessageLoading) return
+ if (isMessageLoading.getAndSet(true)) return
- isMessageLoading = true
viewModelScope.launch {
when (val response = repository.getMessages(it.roomId, lastMessageId)) {
is ApiResponse.Success -> {
@@ -90,7 +91,7 @@ class MessageRoomViewModel @Inject constructor(
MessageRoomEvent.FailureEvent.LoadMessages(ErrorType.UNEXPECTED)
}
}
- isMessageLoading = false
+ isMessageLoading.set(false)
}
}
}
@@ -123,6 +124,7 @@ class MessageRoomViewModel @Inject constructor(
_messageRoomInfo.value?.let {
val message = inputMessage.value
if (message.isNullOrEmpty()) return
+ if (isSubmitLoading.getAndSet(true)) return
val request = ChatMessageRequest(it.messagePartnerId, message)
viewModelScope.launch {
@@ -147,6 +149,7 @@ class MessageRoomViewModel @Inject constructor(
MessageRoomEvent.FailureEvent.SendMessage(ErrorType.UNEXPECTED)
}
}
+ isSubmitLoading.set(false)
}
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionActivity.kt
index d58b54f9a..4fff144d1 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionActivity.kt
@@ -86,6 +86,7 @@ class MyAuctionActivity : BindingActivity(R.layout.act
private fun setupAuctionRecyclerView() {
with(binding.rvMyAuction) {
adapter = auctionAdapter
+ setHasFixedSize(true)
val space = resources.getDimensionPixelSize(R.dimen.margin_side_layout)
addItemDecoration(AuctionSpaceItemDecoration(spanCount = 2, space = space))
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingActivity.kt
new file mode 100644
index 000000000..890e78d4f
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingActivity.kt
@@ -0,0 +1,75 @@
+package com.ddangddangddang.android.feature.onboarding
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.viewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.ActivityOnboardingBinding
+import com.ddangddangddang.android.feature.main.MainActivity
+import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.view.showDialog
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class OnBoardingActivity :
+ BindingActivity(R.layout.activity_onboarding) {
+ private val viewModel: OnBoardingViewModel by viewModels()
+ private val callback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ viewModel.previousPage()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.viewModel = viewModel
+ onBackPressedDispatcher.addCallback(this, callback)
+ setupOnBoardingFragmentAdapter()
+ setupViewModel()
+ }
+
+ private fun setupOnBoardingFragmentAdapter() {
+ binding.vpOnboarding.adapter = OnBoardingFragmentAdapter(this)
+ binding.vpOnboarding.isUserInputEnabled = false // 유저가 직접 스와이프 이동 못하게 막기.
+ }
+
+ private fun setupViewModel() {
+ viewModel.event.observe(this) {
+ handleEvent(it)
+ }
+ viewModel.currentPageType.observe(this) {
+ binding.vpOnboarding.currentItem = it.ordinal // 해당 페이지로 이동
+ }
+ }
+
+ private fun handleEvent(event: OnBoardingViewModel.Event) {
+ when (event) {
+ OnBoardingViewModel.Event.Skip -> showSkipDialog() // 건너 뛰기 버튼을 누르는 경우
+ OnBoardingViewModel.Event.Exit -> navigateToMain() // 모든 과정을 완료했을때
+ }
+ }
+
+ private fun showSkipDialog() {
+ showDialog(
+ titleId = R.string.onboarding_page_skip_dialog_title,
+ messageId = R.string.onboarding_page_skip_dialog_message,
+ positiveStringId = R.string.onboarding_page_skip_dialog_positive_button,
+ negativeStringId = R.string.all_dialog_default_negative_button,
+ actionPositive = { navigateToMain() },
+ isCancelable = false,
+ )
+ }
+
+ private fun navigateToMain() {
+ startActivity(MainActivity.getIntent(this))
+ finish()
+ }
+
+ companion object {
+ fun getIntent(context: Context): Intent {
+ return Intent(context, OnBoardingActivity::class.java)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingFragmentAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingFragmentAdapter.kt
new file mode 100644
index 000000000..27a2b4c05
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingFragmentAdapter.kt
@@ -0,0 +1,16 @@
+package com.ddangddangddang.android.feature.onboarding
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import com.ddangddangddang.android.feature.onboarding.profile.ProfileSettingFragment
+
+class OnBoardingFragmentAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
+ override fun getItemCount(): Int = OnBoardingPageType.values().size
+
+ override fun createFragment(position: Int): Fragment {
+ return when (OnBoardingPageType.values()[position]) {
+ OnBoardingPageType.ProfileSetting -> ProfileSettingFragment()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingPageType.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingPageType.kt
new file mode 100644
index 000000000..a40e65648
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingPageType.kt
@@ -0,0 +1,8 @@
+package com.ddangddangddang.android.feature.onboarding
+
+import androidx.annotation.StringRes
+import com.ddangddangddang.android.R
+
+enum class OnBoardingPageType(@StringRes val titleId: Int) {
+ ProfileSetting(R.string.onboarding_profile_setting_title),
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingViewModel.kt
new file mode 100644
index 000000000..c73062133
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/OnBoardingViewModel.kt
@@ -0,0 +1,46 @@
+package com.ddangddangddang.android.feature.onboarding
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class OnBoardingViewModel @Inject constructor() : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ private val _currentPageType = MutableLiveData(OnBoardingPageType.ProfileSetting)
+ val currentPageType: LiveData
+ get() = _currentPageType
+
+ fun previousPage() {
+ _currentPageType.value?.let {
+ if (it.ordinal == 0) return
+ _currentPageType.value = OnBoardingPageType.values()[it.ordinal - 1]
+ }
+ }
+
+ fun nextPage() {
+ _currentPageType.value?.let {
+ if (it.ordinal == OnBoardingPageType.values().size - 1) return done()
+ _currentPageType.value = OnBoardingPageType.values()[it.ordinal + 1]
+ }
+ }
+
+ private fun done() {
+ _event.value = Event.Exit
+ }
+
+ fun setSkipEvent() {
+ _event.value = Event.Skip
+ }
+
+ sealed class Event {
+ object Skip : Event()
+ object Exit : Event()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/profile/ProfileSettingFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/profile/ProfileSettingFragment.kt
new file mode 100644
index 000000000..ce3b2a69a
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/profile/ProfileSettingFragment.kt
@@ -0,0 +1,91 @@
+package com.ddangddangddang.android.feature.onboarding.profile
+
+import android.content.ContentResolver
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.FragmentProfileSettingBinding
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.feature.common.notifyFailureSnackBar
+import com.ddangddangddang.android.feature.onboarding.OnBoardingViewModel
+import com.ddangddangddang.android.util.binding.BindingFragment
+import com.ddangddangddang.android.util.view.observeLoadingWithDialog
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ProfileSettingFragment :
+ BindingFragment(R.layout.fragment_profile_setting) {
+ private val activityViewModel: OnBoardingViewModel by activityViewModels()
+ private val viewModel: ProfileSettingViewModel by viewModels()
+
+ private val launcher =
+ registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
+ if (uri != null) viewModel.setProfileImageUri(uri)
+ }
+
+ private val defaultUri by lazy {
+ Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(resources.getResourcePackageName(R.drawable.img_default_profile))
+ .appendPath(resources.getResourceTypeName(R.drawable.img_default_profile))
+ .appendPath(resources.getResourceEntryName(R.drawable.img_default_profile))
+ .build()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ requireContext().observeLoadingWithDialog(viewLifecycleOwner, viewModel.isLoading)
+ if (viewModel.profile.value == null) viewModel.setupProfile(defaultUri)
+ setupViewModel()
+ }
+
+ private fun setupViewModel() {
+ viewModel.event.observe(viewLifecycleOwner) {
+ handleEvent(it)
+ }
+ }
+
+ private fun handleEvent(event: ProfileSettingViewModel.Event) {
+ when (event) {
+ is ProfileSettingViewModel.Event.FailureInitSetupProfileEvent -> {
+ notifyProfileInitLoadFailed(event.errorType)
+ }
+
+ is ProfileSettingViewModel.Event.FailureChangeProfileEvent -> {
+ notifyProfileChangeFailed(event.errorType)
+ }
+
+ ProfileSettingViewModel.Event.NavigateToNext -> {
+ activityViewModel.nextPage()
+ }
+
+ ProfileSettingViewModel.Event.NavigateToSelectProfileImage -> {
+ launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
+ }
+ }
+ }
+
+ private fun notifyProfileInitLoadFailed(type: ErrorType) {
+ notifyFailureSnackBar(
+ binding.root,
+ type,
+ R.string.mypage_snackbar_load_profile_failed_title,
+ R.string.all_snackbar_default_action,
+ )
+ }
+
+ private fun notifyProfileChangeFailed(type: ErrorType) {
+ notifyFailureSnackBar(
+ binding.root,
+ type,
+ R.string.profile_change_failed,
+ R.string.all_snackbar_default_action,
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/profile/ProfileSettingViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/profile/ProfileSettingViewModel.kt
new file mode 100644
index 000000000..e836af462
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/onboarding/profile/ProfileSettingViewModel.kt
@@ -0,0 +1,127 @@
+package com.ddangddangddang.android.feature.onboarding.profile
+
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.mapper.ProfileModelMapper.toPresentation
+import com.ddangddangddang.android.util.image.toAdjustImageFile
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.request.ProfileUpdateRequest
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.UserRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ProfileSettingViewModel @Inject constructor(
+ private val userRepository: UserRepository,
+) : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ private lateinit var currentProfileUri: Uri
+ private lateinit var currentProfileName: String
+
+ private val _profile: MutableLiveData = MutableLiveData()
+ val profile: LiveData
+ get() = _profile
+
+ val userNickname: MutableLiveData = MutableLiveData()
+
+ private val _isLoading: MutableLiveData = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
+ // 처음 프로필 설정 정보를 불러오는 과정. 이것은 전체를 온보딩 작업 내에서 딱 한 번만 일어난다.
+ fun setupProfile(defaultUri: Uri) {
+ if (_isLoading.value == true) return
+ _isLoading.value = true
+
+ viewModelScope.launch {
+ when (val response = userRepository.getProfile()) {
+ is ApiResponse.Success -> {
+ val profileModel = response.body.toPresentation()
+ val originProfileUri =
+ profileModel.profileImage?.let { Uri.parse(it) } ?: defaultUri
+ _profile.value = originProfileUri
+ userNickname.value = profileModel.name.trim()
+ currentProfileUri = originProfileUri
+ currentProfileName = profileModel.name.trim()
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value =
+ Event.FailureInitSetupProfileEvent(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = Event.FailureInitSetupProfileEvent(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = Event.FailureInitSetupProfileEvent(ErrorType.UNEXPECTED)
+ }
+ }
+ _isLoading.value = false
+ }
+ }
+
+ fun setProfileImageUri(uri: Uri) {
+ _profile.value = uri
+ }
+
+ fun selectProfileImage() {
+ _event.value = Event.NavigateToSelectProfileImage
+ }
+
+ fun submitProfile(context: Context) {
+ val name = userNickname.value?.trim() ?: return
+ val profileImageUri = profile.value?.takeIf { it.path != currentProfileUri.path }
+
+ // 만약 이전과 상태가 같다면, 변경 요청 보내지 않을 것임. 그냥 다음 페이지로 이동
+ if (name == currentProfileName && profileImageUri == null) return setNavigateToNextEvent()
+
+ if (_isLoading.value == true) return
+ _isLoading.value = true
+ viewModelScope.launch {
+ val file = runCatching { profileImageUri?.toAdjustImageFile(context) }.getOrNull()
+ when (val response = userRepository.updateProfile(file, ProfileUpdateRequest(name))) {
+ is ApiResponse.Success -> {
+ currentProfileUri = profileImageUri ?: currentProfileUri
+ setNavigateToNextEvent()
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value =
+ Event.FailureChangeProfileEvent(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = Event.FailureChangeProfileEvent(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = Event.FailureChangeProfileEvent(ErrorType.UNEXPECTED)
+ }
+ }
+ _isLoading.value = false
+ }
+ }
+
+ private fun setNavigateToNextEvent() {
+ _event.value = Event.NavigateToNext
+ }
+
+ sealed class Event {
+ data class FailureInitSetupProfileEvent(val errorType: ErrorType) : Event()
+ data class FailureChangeProfileEvent(val errorType: ErrorType) : Event()
+ object NavigateToSelectProfileImage : Event()
+ object NavigateToNext : Event()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionActivity.kt
index 9db98a34d..5f7ff0db7 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionActivity.kt
@@ -87,6 +87,7 @@ class ParticipateAuctionActivity :
private fun setupAuctionRecyclerView() {
with(binding.rvMyParticipateAuction) {
adapter = auctionAdapter
+ setHasFixedSize(true)
val space = resources.getDimensionPixelSize(R.dimen.margin_side_layout)
addItemDecoration(AuctionSpaceItemDecoration(spanCount = 2, space = space))
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeViewModel.kt
index 7b70e6a83..dc90c8cb6 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeViewModel.kt
@@ -53,7 +53,7 @@ class ProfileChangeViewModel @Inject constructor(
}
fun submitProfile(context: Context) {
- val name = userNickname.value ?: return
+ val name = userNickname.value?.trim() ?: return
val profileImageUri = profile.value?.takeIf { it.path != originalProfileUri.path }
viewModelScope.launch {
val file = runCatching { profileImageUri?.toAdjustImageFile(context) }.getOrNull()
@@ -63,7 +63,8 @@ class ProfileChangeViewModel @Inject constructor(
}
is ApiResponse.Failure -> {
- _event.value = Event.FailureChangeProfileEvent(ErrorType.FAILURE(response.error))
+ _event.value =
+ Event.FailureChangeProfileEvent(ErrorType.FAILURE(response.error))
}
is ApiResponse.NetworkError -> {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/DefaultTextWatcher.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/DefaultTextWatcher.kt
deleted file mode 100644
index 65857654e..000000000
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/DefaultTextWatcher.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.ddangddangddang.android.feature.register
-
-import android.text.Editable
-import android.text.TextWatcher
-
-class DefaultTextWatcher(private val onAfterChanged: (String) -> Unit) : TextWatcher {
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
-
- override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
-
- override fun afterTextChanged(s: Editable?) {
- s?.let { onAfterChanged(s.toString()) }
- }
-}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt
index c7be9b40b..6bb3a8289 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt
@@ -5,7 +5,9 @@ import android.app.TimePickerDialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.view.inputmethod.EditorInfo
import android.widget.EditText
+import android.widget.TextView.OnEditorActionListener
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -13,7 +15,9 @@ import androidx.activity.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityRegisterAuctionBinding
import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.feature.common.PriceTextWatcher
import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
+import com.ddangddangddang.android.feature.register.RegisterAuctionViewModel.Companion.SUFFIX_INPUT_PRICE
import com.ddangddangddang.android.feature.register.category.SelectCategoryActivity
import com.ddangddangddang.android.feature.register.region.SelectRegionsActivity
import com.ddangddangddang.android.global.AnalyticsDelegate
@@ -29,6 +33,7 @@ import com.ddangddangddang.android.util.view.showSnackbar
import dagger.hilt.android.AndroidEntryPoint
import java.time.LocalDateTime
import java.time.LocalTime
+import java.util.Calendar
@AndroidEntryPoint
class RegisterAuctionActivity :
@@ -39,8 +44,8 @@ class RegisterAuctionActivity :
private val pickMultipleMediaLaunchers = setupMultipleMediaLaunchers()
private val categoryActivityLauncher = setupCategoryLauncher()
private val regionActivityLauncher = setupRegionLauncher()
- private val startPriceWatcher by lazy { DefaultTextWatcher(viewModel::setStartPrice) }
- private val bidUnitWatcher by lazy { DefaultTextWatcher(viewModel::setBidUnit) }
+ private val startPriceWatcher by lazy { PriceTextWatcher { viewModel.setStartPrice(it) } }
+ private val bidUnitWatcher by lazy { PriceTextWatcher { viewModel.setBidUnit(it) } }
private fun setupMultipleMediaLaunchers(): List> {
return List(RegisterAuctionViewModel.MAXIMUM_IMAGE_SIZE) { index ->
@@ -95,6 +100,7 @@ class RegisterAuctionActivity :
setupImageRecyclerView()
setupStartPriceTextWatcher()
setupBidUnitTextWatcher()
+ setupEditTextClearFocus()
}
private fun setupViewModel() {
@@ -146,11 +152,13 @@ class RegisterAuctionActivity :
}
RegisterAuctionViewModel.RegisterAuctionEvent.PickCategory -> {
+ currentFocus?.clearFocus()
navigationToCategorySelection()
}
- RegisterAuctionViewModel.RegisterAuctionEvent.PickRegion -> {
- navigationToRegionSelection()
+ is RegisterAuctionViewModel.RegisterAuctionEvent.PickRegion -> {
+ currentFocus?.clearFocus()
+ navigationToRegionSelection(event.regionSelected)
}
}
}
@@ -168,7 +176,12 @@ class RegisterAuctionActivity :
selectedDateTime.year,
selectedDateTime.monthValue - 1,
selectedDateTime.dayOfMonth,
- ).show()
+ ).apply {
+ val calendar: Calendar = Calendar.getInstance()
+ this.datePicker.minDate = calendar.timeInMillis
+ calendar.add(Calendar.DATE, 29)
+ this.datePicker.maxDate = calendar.timeInMillis
+ }.show()
}
private fun showTimePicker(selectedTime: LocalTime) {
@@ -216,15 +229,24 @@ class RegisterAuctionActivity :
categoryActivityLauncher.launch(SelectCategoryActivity.getIntent(this))
}
- private fun navigationToRegionSelection() {
- regionActivityLauncher.launch(SelectRegionsActivity.getIntent(this))
+ private fun navigationToRegionSelection(directRegion: List) {
+ regionActivityLauncher.launch(SelectRegionsActivity.getIntent(this, directRegion))
}
- private fun setPrice(editText: EditText, watcher: DefaultTextWatcher, price: Int) {
- val displayPrice = getString(R.string.detail_auction_bid_dialog_input_price, price)
+ private fun setPrice(
+ editText: EditText,
+ watcher: PriceTextWatcher,
+ price: Int,
+ ) {
+ val displayPrice = getString(R.string.all_price, price)
editText.removeTextChangedListener(watcher)
editText.setText(displayPrice)
- editText.setSelection(getCursorPositionFrontSuffix(displayPrice)) // " 원" 앞으로 커서 이동
+ editText.setSelection(
+ watcher.getCursorPosition(
+ displayPrice.length,
+ SUFFIX_INPUT_PRICE.length,
+ ),
+ ) // 이전 커서 위치로 이동
editText.addTextChangedListener(watcher)
}
@@ -250,6 +272,7 @@ class RegisterAuctionActivity :
private fun setupImageRecyclerView() {
with(binding.rvImage) {
adapter = imageAdapter
+ setHasFixedSize(true)
addItemDecoration(RegisterAuctionImageSpaceItemDecoration(space = 24))
}
}
@@ -262,8 +285,21 @@ class RegisterAuctionActivity :
binding.etBidUnit.addTextChangedListener(bidUnitWatcher)
}
- private fun getCursorPositionFrontSuffix(content: String): Int {
- return content.length - RegisterAuctionViewModel.SUFFIX_INPUT_PRICE.length
+ private fun setupEditTextClearFocus() {
+ val editActionListener = OnEditorActionListener { v, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) v.clearFocus()
+ return@OnEditorActionListener false
+ }
+
+ val editTexts = listOf(
+ binding.etTitle,
+ binding.etStartPrice,
+ binding.etBidUnit,
+ )
+
+ editTexts.forEach {
+ it.setOnEditorActionListener(editActionListener)
+ }
}
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt
index d984a513c..8f5c34064 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt
@@ -204,7 +204,7 @@ class RegisterAuctionViewModel @Inject constructor(private val repository: Aucti
}
fun setPickRegionEvent() {
- _event.value = RegisterAuctionEvent.PickRegion
+ _event.value = RegisterAuctionEvent.PickRegion(_directRegion.value ?: emptyList())
}
private fun setBlankExistEvent() {
@@ -237,7 +237,7 @@ class RegisterAuctionViewModel @Inject constructor(private val repository: Aucti
object MultipleMediaPicker : RegisterAuctionEvent()
object PickCategory : RegisterAuctionEvent()
- object PickRegion : RegisterAuctionEvent()
+ class PickRegion(val regionSelected: List) : RegisterAuctionEvent()
}
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt
index a35ae75fb..bab0145af 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt
@@ -10,6 +10,7 @@ import com.ddangddangddang.android.feature.common.notifyFailureMessage
import com.ddangddangddang.android.feature.register.RegisterAuctionActivity
import com.ddangddangddang.android.model.RegionSelectionModel
import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.compat.getSerializableExtraCompat
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -43,6 +44,7 @@ class SelectRegionsActivity :
setupAdapter()
setupObserve()
viewModel.loadFirstRegions()
+ setupRegionSelected()
}
private fun setupAdapter() {
@@ -98,7 +100,19 @@ class SelectRegionsActivity :
finish()
}
+ private fun setupRegionSelected() {
+ val regionSelected =
+ intent.getSerializableExtraCompat>(KEY_REGION_SELECTED)
+ regionSelected?.let { viewModel.setupRegionSelected(it.toList()) }
+ }
+
companion object {
- fun getIntent(context: Context): Intent = Intent(context, SelectRegionsActivity::class.java)
+ private const val KEY_REGION_SELECTED = "region_selected"
+
+ fun getIntent(context: Context, regionSelected: List): Intent {
+ val intent = Intent(context, SelectRegionsActivity::class.java)
+ intent.putExtra(KEY_REGION_SELECTED, regionSelected.toTypedArray())
+ return intent
+ }
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt
index f60805b13..c89da5fd8 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt
@@ -70,6 +70,10 @@ class SelectRegionsViewModel @Inject constructor(private val regionRepository: R
}
}
+ fun setupRegionSelected(regionSelected: List) {
+ _regionSelections.value = regionSelected
+ }
+
fun setExitEvent() {
_event.value = SelectRegionsEvent.Exit
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt
index bf8ff8959..af2d469ac 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt
@@ -7,8 +7,9 @@ import androidx.activity.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityReportBinding
import com.ddangddangddang.android.feature.common.notifyFailureMessage
-import com.ddangddangddang.android.model.ReportType
+import com.ddangddangddang.android.model.ReportInfo
import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.compat.getSerializableExtraCompat
import com.ddangddangddang.android.util.view.Toaster
import com.ddangddangddang.android.util.view.showSnackbar
import dagger.hilt.android.AndroidEntryPoint
@@ -29,11 +30,7 @@ class ReportActivity : BindingActivity(R.layout.activity_
ReportViewModel.ReportEvent.ExitEvent -> finish()
ReportViewModel.ReportEvent.SubmitEvent -> submit()
ReportViewModel.ReportEvent.BlankContentsEvent -> notifyBlankContents()
- is ReportViewModel.ReportEvent.ReportArticleFailure -> {
- notifyFailureMessage(event.error, R.string.report_submit_failure)
- }
-
- is ReportViewModel.ReportEvent.ReportMessageRoomFailure -> {
+ is ReportViewModel.ReportEvent.ReportFailure -> {
notifyFailureMessage(event.error, R.string.report_submit_failure)
}
}
@@ -41,10 +38,12 @@ class ReportActivity : BindingActivity(R.layout.activity_
}
private fun getReportInfo() {
- val typeIndex: Int = intent.getIntExtra(REPORT_TYPE_KEY, DEFAULT_VALUE.toInt())
- val id = intent.getLongExtra(REPORT_ID_KEY, DEFAULT_VALUE)
- if (id == DEFAULT_VALUE || typeIndex == DEFAULT_VALUE.toInt()) notifyNavigateToReportPageFailed()
- viewModel.setReportInfo(ReportType.values()[typeIndex], id)
+ val info = intent.getSerializableExtraCompat(REPORT_ID_KEY)
+ if (info == null) {
+ notifyNavigateToReportPageFailed()
+ return
+ }
+ viewModel.setReportInfo(info)
}
private fun submit() {
@@ -62,13 +61,10 @@ class ReportActivity : BindingActivity(R.layout.activity_
}
companion object {
- private const val DEFAULT_VALUE = -1L
- private const val REPORT_TYPE_KEY = "report_type_key"
private const val REPORT_ID_KEY = "report_id_key"
- fun getIntent(context: Context, reportTypeIndex: Int, reportId: Long): Intent =
+ fun getIntent(context: Context, info: ReportInfo): Intent =
Intent(context, ReportActivity::class.java).apply {
- putExtra(REPORT_TYPE_KEY, reportTypeIndex)
- putExtra(REPORT_ID_KEY, reportId)
+ putExtra(REPORT_ID_KEY, info)
}
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt
index e23d3f95b..edd473834 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt
@@ -5,8 +5,10 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ddangddangddang.android.feature.common.ErrorType
-import com.ddangddangddang.android.model.ReportType
+import com.ddangddangddang.android.model.ReportInfo
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.request.ReportAnswerRequest
+import com.ddangddangddang.data.model.request.ReportQuestionRequest
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuctionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -19,15 +21,13 @@ class ReportViewModel @Inject constructor(private val repository: AuctionReposit
val event: LiveData
get() = _event
- private lateinit var reportType: ReportType
- private var reportId: Long? = null
+ private lateinit var reportInfo: ReportInfo
val reportContents = MutableLiveData()
private var isLoading: Boolean = false
- fun setReportInfo(type: ReportType, id: Long) {
- reportType = type
- reportId = id
+ fun setReportInfo(info: ReportInfo) {
+ reportInfo = info
}
fun setExitEvent() {
@@ -39,10 +39,11 @@ class ReportViewModel @Inject constructor(private val repository: AuctionReposit
}
fun submit() {
- val reportId: Long = reportId ?: return
- when (reportType) {
- ReportType.ArticleReport -> reportAuctionArticle(reportId)
- ReportType.MessageRoomReport -> reportMessageRoom(reportId)
+ when (reportInfo) {
+ is ReportInfo.ArticleReportInfo -> reportAuctionArticle((reportInfo as ReportInfo.ArticleReportInfo).auctionId)
+ is ReportInfo.MessageRoomReportInfo -> reportMessageRoom((reportInfo as ReportInfo.MessageRoomReportInfo).roomId)
+ is ReportInfo.QuestionReportInfo -> reportQuestion(reportInfo as ReportInfo.QuestionReportInfo)
+ is ReportInfo.AnswerReportInfo -> reportAnswer(reportInfo as ReportInfo.AnswerReportInfo)
}
}
@@ -56,17 +57,17 @@ class ReportViewModel @Inject constructor(private val repository: AuctionReposit
is ApiResponse.Success -> _event.value = ReportEvent.SubmitEvent // 정상적인 신고 접수
is ApiResponse.Failure -> {
_event.value =
- ReportEvent.ReportArticleFailure(ErrorType.FAILURE(response.error))
+ ReportEvent.ReportFailure(ErrorType.FAILURE(response.error))
}
is ApiResponse.NetworkError -> {
_event.value =
- ReportEvent.ReportArticleFailure(ErrorType.NETWORK_ERROR)
+ ReportEvent.ReportFailure(ErrorType.NETWORK_ERROR)
}
is ApiResponse.Unexpected -> {
_event.value =
- ReportEvent.ReportArticleFailure(ErrorType.UNEXPECTED)
+ ReportEvent.ReportFailure(ErrorType.UNEXPECTED)
}
}
}
@@ -84,17 +85,77 @@ class ReportViewModel @Inject constructor(private val repository: AuctionReposit
is ApiResponse.Success -> _event.value = ReportEvent.SubmitEvent // 정상적인 신고 접수
is ApiResponse.Failure -> {
_event.value =
- ReportEvent.ReportMessageRoomFailure(ErrorType.FAILURE(response.error))
+ ReportEvent.ReportFailure(ErrorType.FAILURE(response.error))
}
is ApiResponse.NetworkError -> {
_event.value =
- ReportEvent.ReportMessageRoomFailure(ErrorType.NETWORK_ERROR)
+ ReportEvent.ReportFailure(ErrorType.NETWORK_ERROR)
}
is ApiResponse.Unexpected -> {
_event.value =
- ReportEvent.ReportMessageRoomFailure(ErrorType.UNEXPECTED)
+ ReportEvent.ReportFailure(ErrorType.UNEXPECTED)
+ }
+ }
+ }
+ isLoading = false
+ }
+ }
+
+ private fun reportQuestion(reportInfo: ReportInfo.QuestionReportInfo) {
+ if (isLoading) return
+ isLoading = true
+ viewModelScope.launch {
+ reportContents.value?.let { contents ->
+ if (contents.isEmpty()) return@launch setBlankContentsEvent() // 내용이 비어있는 경우
+ val request =
+ ReportQuestionRequest(reportInfo.auctionId, reportInfo.questionId, contents)
+ when (val response = repository.reportQuestion(request)) {
+ is ApiResponse.Success -> _event.value = ReportEvent.SubmitEvent // 정상적인 신고 접수
+ is ApiResponse.Failure -> {
+ _event.value =
+ ReportEvent.ReportFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ ReportEvent.ReportFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ ReportEvent.ReportFailure(ErrorType.UNEXPECTED)
+ }
+ }
+ }
+ isLoading = false
+ }
+ }
+
+ private fun reportAnswer(reportInfo: ReportInfo.AnswerReportInfo) {
+ if (isLoading) return
+ isLoading = true
+ viewModelScope.launch {
+ reportContents.value?.let { contents ->
+ if (contents.isEmpty()) return@launch setBlankContentsEvent() // 내용이 비어있는 경우
+ val request =
+ ReportAnswerRequest(reportInfo.auctionId, reportInfo.answerId, contents)
+ when (val response = repository.reportAnswer(request)) {
+ is ApiResponse.Success -> _event.value = ReportEvent.SubmitEvent // 정상적인 신고 접수
+ is ApiResponse.Failure -> {
+ _event.value =
+ ReportEvent.ReportFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ ReportEvent.ReportFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ ReportEvent.ReportFailure(ErrorType.UNEXPECTED)
}
}
}
@@ -106,7 +167,6 @@ class ReportViewModel @Inject constructor(private val repository: AuctionReposit
object ExitEvent : ReportEvent()
object SubmitEvent : ReportEvent()
object BlankContentsEvent : ReportEvent()
- data class ReportArticleFailure(val error: ErrorType) : ReportEvent()
- data class ReportMessageRoomFailure(val error: ErrorType) : ReportEvent()
+ data class ReportFailure(val error: ErrorType) : ReportEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt
index 91ab9a3e4..8d49c1285 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt
@@ -52,6 +52,7 @@ class SearchFragment : BindingFragment(R.layout.fragment_
private fun setupAuctionRecyclerView() {
with(binding.rvSearchAuctions) {
adapter = auctionAdapter
+ setHasFixedSize(true)
addItemDecoration(
AuctionSpaceItemDecoration(
2,
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt
index 47c34ffb0..b87ccba42 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt
@@ -55,12 +55,12 @@ class SplashActivity : BindingActivity(R.layout.activity_
}
private fun navigateToMain() {
- startActivity(Intent(this, MainActivity::class.java))
+ startActivity(MainActivity.getIntent(this))
finish()
}
private fun navigateToLogin() {
- startActivity(Intent(this, LoginActivity::class.java))
+ startActivity(LoginActivity.getIntent(this))
finish()
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/global/UrlConverter.kt b/android/app/src/main/java/com/ddangddangddang/android/global/UrlConverter.kt
new file mode 100644
index 000000000..b631d04de
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/global/UrlConverter.kt
@@ -0,0 +1,17 @@
+package com.ddangddangddang.android.global
+
+import android.content.Context
+import android.graphics.Bitmap
+import com.bumptech.glide.Glide
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+suspend fun getBitmapFromUrl(context: Context, url: String): Bitmap {
+ return withContext(Dispatchers.IO) {
+ Glide.with(context.applicationContext)
+ .asBitmap()
+ .load(url)
+ .submit()
+ .get()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt
index b81b95378..bc7ba7876 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt
@@ -20,4 +20,5 @@ data class AuctionDetailModel(
val sellerModel: SellerModel,
val chatAuctionDetailModel: ChatAuctionDetailModel,
val isOwner: Boolean,
+ val isLastBidder: Boolean,
)
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/BidHistoryModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/BidHistoryModel.kt
new file mode 100644
index 000000000..885eb269e
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/BidHistoryModel.kt
@@ -0,0 +1,10 @@
+package com.ddangddangddang.android.model
+
+import java.time.LocalDateTime
+
+data class BidHistoryModel(
+ val name: String,
+ val profileImage: String,
+ val price: Int,
+ val bidDateTime: LocalDateTime,
+)
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/QnaModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/QnaModel.kt
new file mode 100644
index 000000000..910ac630a
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/QnaModel.kt
@@ -0,0 +1,24 @@
+package com.ddangddangddang.android.model
+
+data class QnaModel(val questionAndAnswers: List) {
+ data class QuestionAndAnswerModel(
+ val question: QuestionModel,
+ val answer: AnswerModel?,
+ val isOwner: Boolean,
+ val isPicked: Boolean,
+ val status: QnaStatusModel,
+ )
+
+ data class QuestionModel(
+ val id: Long,
+ val createdTime: String,
+ val content: String,
+ val isQuestioner: Boolean,
+ )
+
+ data class AnswerModel(
+ val id: Long,
+ val createdTime: String,
+ val content: String,
+ )
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/QnaStatusModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/QnaStatusModel.kt
new file mode 100644
index 000000000..c229865cf
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/QnaStatusModel.kt
@@ -0,0 +1,24 @@
+package com.ddangddangddang.android.model
+
+import androidx.annotation.ColorRes
+import androidx.annotation.StringRes
+import com.ddangddangddang.android.R
+
+enum class QnaStatusModel(
+ @StringRes val questionStatus: Int,
+ @ColorRes val colorId: Int,
+) {
+ WAITING(R.string.detail_auction_qna_waiting, R.color.grey_700),
+ COMPLETE(R.string.detail_auction_qna_complete, R.color.red_300),
+ ;
+
+ companion object {
+ fun find(isExistAnswer: Boolean): QnaStatusModel {
+ return if (isExistAnswer) {
+ COMPLETE
+ } else {
+ WAITING
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/ReportInfo.kt b/android/app/src/main/java/com/ddangddangddang/android/model/ReportInfo.kt
new file mode 100644
index 000000000..5f8fea368
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/ReportInfo.kt
@@ -0,0 +1,10 @@
+package com.ddangddangddang.android.model
+
+import java.io.Serializable
+
+sealed class ReportInfo : Serializable {
+ data class ArticleReportInfo(val auctionId: Long) : ReportInfo()
+ data class MessageRoomReportInfo(val roomId: Long) : ReportInfo()
+ data class QuestionReportInfo(val auctionId: Long, val questionId: Long) : ReportInfo()
+ data class AnswerReportInfo(val auctionId: Long, val questionId: Long, val answerId: Long) : ReportInfo()
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/ReportType.kt b/android/app/src/main/java/com/ddangddangddang/android/model/ReportType.kt
deleted file mode 100644
index 04ffc5304..000000000
--- a/android/app/src/main/java/com/ddangddangddang/android/model/ReportType.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package com.ddangddangddang.android.model
-
-enum class ReportType {
- ArticleReport, MessageRoomReport
-}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt
index 27ec59546..323a897e7 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt
@@ -5,4 +5,8 @@ data class SellerModel(
val profileUrl: String,
val nickname: String,
val reliability: Float?,
-)
+) {
+ fun toProfileModel(): ProfileModel {
+ return ProfileModel(nickname, profileUrl, reliability)
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/mapper/AuctionBidHistoryModelMapper.kt b/android/app/src/main/java/com/ddangddangddang/android/model/mapper/AuctionBidHistoryModelMapper.kt
new file mode 100644
index 000000000..48d68dbae
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/mapper/AuctionBidHistoryModelMapper.kt
@@ -0,0 +1,16 @@
+package com.ddangddangddang.android.model.mapper
+
+import com.ddangddangddang.android.model.BidHistoryModel
+import com.ddangddangddang.data.model.response.BidHistoryResponse
+import java.time.LocalDateTime
+
+object AuctionBidHistoryModelMapper : Mapper {
+ override fun BidHistoryResponse.toPresentation(): BidHistoryModel {
+ return BidHistoryModel(
+ name,
+ profileImage,
+ price,
+ LocalDateTime.parse(bidTime),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/mapper/AuctionDetailModelMapper.kt b/android/app/src/main/java/com/ddangddangddang/android/model/mapper/AuctionDetailModelMapper.kt
index 86867f38d..d84fc8075 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/mapper/AuctionDetailModelMapper.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/mapper/AuctionDetailModelMapper.kt
@@ -34,6 +34,7 @@ object AuctionDetailModelMapper : Mapper {
- val activeRoomId = (application as DdangDdangDdang).activeMessageRoomId
- if (activeRoomId == id) {
- sendBroadcastToMessageReceiver(id)
- } else {
- val notification = createMessageNotification(tag, id, remoteMessage)
- notificationManager.notify(tag, id.toInt(), notification)
- }
- }
- NotificationType.BID -> {
- val notification = createBidNotification(id, remoteMessage)
- notificationManager.notify(tag, id.toInt(), notification)
- }
+ if (type is MessageType) {
+ val activeRoomId = (application as DdangDdangDdang).activeMessageRoomId
+ sendBroadcastToMessageReceiver(id) // 항상 호출. 이 리시버를 받을지 말지는 거기서 정함.
+ if (activeRoomId == id) return
}
+
+ val notification = type.createNotification(applicationContext, id, remoteMessage)
+ notificationManager.notify(type.tag, id.toInt(), notification)
}
}
@@ -89,96 +64,8 @@ class DdangDdangDdangFirebaseMessagingService : FirebaseMessagingService() {
return notificationManager.areNotificationsEnabled()
}
- private fun createMessageNotification(
- tag: String,
- id: Long,
- remoteMessage: RemoteMessage,
- ): Notification {
- return runBlocking {
- val image = runCatching {
- getBitmapFromUrl(remoteMessage.data["image"] ?: "")
- }.getOrDefault(defaultImage)
- val activeNotification = getActiveNotification(tag, id.toInt())
- val currentLine = remoteMessage.data["body"] ?: ""
- val pendingIntent =
- activeNotification?.contentIntent ?: getMessageRoomPendingIntent(id)
-
- Notification.Builder(applicationContext, CHANNEL_ID).apply {
- setSmallIcon(R.drawable.img_logo)
- setLargeIcon(image)
- setShowWhen(true)
- setContentTitle(remoteMessage.data["title"])
- setContentText(currentLine)
- style = getMessageInboxStyle(activeNotification, currentLine)
- setContentIntent(pendingIntent)
- setAutoCancel(true)
- }.build()
- }
- }
-
- private suspend fun getBitmapFromUrl(url: String): Bitmap {
- return withContext(Dispatchers.IO) {
- Glide.with(applicationContext)
- .asBitmap()
- .load(url)
- .submit()
- .get()
- }
- }
-
- private fun getMessageInboxStyle(
- activeNotification: Notification?,
- currentLine: String,
- ): InboxStyle {
- val previousLines =
- activeNotification?.extras?.getCharSequenceArray(EXTRA_TEXT_LINES) ?: emptyArray()
- val lines = previousLines.plus(currentLine)
-
- return InboxStyle().apply {
- lines.forEach { addLine(it) }
- setSummaryText("${lines.size}개의 메시지")
- }
- }
-
- private fun getMessageRoomPendingIntent(id: Long): PendingIntent? {
- val intent = MessageRoomActivity.getIntent(applicationContext, id)
- return intent.getPendingIntent(id.toInt())
- }
-
- private fun Intent.getPendingIntent(requestCode: Int): PendingIntent? {
- return PendingIntent.getActivity(
- applicationContext,
- requestCode,
- this,
- FLAG_IMMUTABLE,
- )
- }
-
private fun sendBroadcastToMessageReceiver(roomId: Long) {
val intent = MessageReceiver.getIntent(roomId)
sendBroadcast(intent)
}
-
- private fun createBidNotification(id: Long, remoteMessage: RemoteMessage): Notification {
- return runBlocking {
- val image = runCatching {
- getBitmapFromUrl(remoteMessage.data["image"] ?: "")
- }.getOrDefault(defaultImage)
- val pendingIntent = getAuctionDetailPendingIntent(id)
-
- Notification.Builder(applicationContext, CHANNEL_ID).apply {
- setSmallIcon(R.drawable.img_logo)
- setLargeIcon(image)
- setContentTitle(remoteMessage.data["title"])
- setContentText(remoteMessage.data["body"])
- setContentIntent(pendingIntent)
- setAutoCancel(true)
- }.build()
- }
- }
-
- private fun getAuctionDetailPendingIntent(id: Long): PendingIntent? {
- val intent = AuctionDetailActivity.getIntent(applicationContext, id)
- return intent.getPendingIntent(id.toInt())
- }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationType.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationType.kt
deleted file mode 100644
index 5c66943c2..000000000
--- a/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationType.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.ddangddangddang.android.notification
-
-enum class NotificationType(private val value: String) {
- MESSAGE("message"), BID("bid");
-
- companion object {
- fun of(value: String): NotificationType? {
- return values().find { it.value == value }
- }
- }
-}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/type/AnswerType.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/type/AnswerType.kt
new file mode 100644
index 000000000..ec7ebf241
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/type/AnswerType.kt
@@ -0,0 +1,47 @@
+package com.ddangddangddang.android.notification.type
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.global.DdangDdangDdang
+import com.ddangddangddang.android.global.getBitmapFromUrl
+import com.ddangddangddang.android.notification.CHANNEL_ID
+import com.google.firebase.messaging.RemoteMessage
+import kotlinx.coroutines.runBlocking
+
+object AnswerType : AuctionDetailType() {
+ private val defaultImage: Bitmap by lazy {
+ BitmapFactory.decodeResource(
+ DdangDdangDdang.resources,
+ R.drawable.img_default_qna,
+ )
+ }
+ private const val SUB_TEXT = "답변이 도착했어요!"
+ override val tag: String
+ get() = "${this::class.java.simpleName}_${System.currentTimeMillis()}"
+
+ override fun createNotification(
+ context: Context,
+ id: Long,
+ remoteMessage: RemoteMessage,
+ ): Notification {
+ return runBlocking {
+ val image = runCatching {
+ getBitmapFromUrl(context, remoteMessage.data["image"] ?: "")
+ }.getOrDefault(defaultImage)
+ val pendingIntent = getAuctionDetailPendingIntent(context, id)
+
+ Notification.Builder(context.applicationContext, CHANNEL_ID).apply {
+ setSmallIcon(R.drawable.img_logo)
+ setLargeIcon(image)
+ setContentTitle(remoteMessage.data["title"])
+ setSubText(SUB_TEXT)
+ setContentText(remoteMessage.data["body"])
+ setContentIntent(pendingIntent)
+ setAutoCancel(true)
+ }.build()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/type/BidType.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/type/BidType.kt
new file mode 100644
index 000000000..fc657a1b1
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/type/BidType.kt
@@ -0,0 +1,44 @@
+package com.ddangddangddang.android.notification.type
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.global.DdangDdangDdang
+import com.ddangddangddang.android.global.getBitmapFromUrl
+import com.ddangddangddang.android.notification.CHANNEL_ID
+import com.google.firebase.messaging.RemoteMessage
+import kotlinx.coroutines.runBlocking
+
+object BidType : AuctionDetailType() {
+ private val defaultImage: Bitmap by lazy {
+ BitmapFactory.decodeResource(
+ DdangDdangDdang.resources,
+ R.drawable.img_default_auction,
+ )
+ }
+ override val tag: String = this::class.java.simpleName
+
+ override fun createNotification(
+ context: Context,
+ id: Long,
+ remoteMessage: RemoteMessage,
+ ): Notification {
+ return runBlocking {
+ val image = runCatching {
+ getBitmapFromUrl(context, remoteMessage.data["image"] ?: "")
+ }.getOrDefault(defaultImage)
+ val pendingIntent = getAuctionDetailPendingIntent(context, id)
+
+ Notification.Builder(context.applicationContext, CHANNEL_ID).apply {
+ setSmallIcon(R.drawable.img_logo)
+ setLargeIcon(image)
+ setContentTitle(remoteMessage.data["title"])
+ setContentText(remoteMessage.data["body"])
+ setContentIntent(pendingIntent)
+ setAutoCancel(true)
+ }.build()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/type/MessageType.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/type/MessageType.kt
new file mode 100644
index 000000000..d33ca4352
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/type/MessageType.kt
@@ -0,0 +1,75 @@
+package com.ddangddangddang.android.notification.type
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.feature.main.MainActivity
+import com.ddangddangddang.android.feature.main.MainFragmentType
+import com.ddangddangddang.android.feature.messageRoom.MessageRoomActivity
+import com.ddangddangddang.android.global.DdangDdangDdang
+import com.ddangddangddang.android.global.getBitmapFromUrl
+import com.ddangddangddang.android.notification.CHANNEL_ID
+import com.ddangddangddang.android.notification.getActiveNotification
+import com.google.firebase.messaging.RemoteMessage
+import kotlinx.coroutines.runBlocking
+
+object MessageType : NotificationType() {
+ private val defaultImage: Bitmap by lazy {
+ BitmapFactory.decodeResource(
+ DdangDdangDdang.resources,
+ R.drawable.img_default_profile,
+ )
+ }
+ override val tag: String = this::class.java.simpleName
+
+ override fun createNotification(
+ context: Context,
+ id: Long,
+ remoteMessage: RemoteMessage,
+ ): Notification {
+ return runBlocking {
+ val image = runCatching {
+ getBitmapFromUrl(context, remoteMessage.data["image"] ?: "")
+ }.getOrDefault(defaultImage)
+ val activeNotification = context.getActiveNotification(tag, id.toInt())
+ val currentLine = remoteMessage.data["body"] ?: ""
+ val pendingIntent =
+ activeNotification?.contentIntent ?: getMessageRoomPendingIntent(context, id)
+
+ Notification.Builder(context.applicationContext, CHANNEL_ID).apply {
+ setSmallIcon(R.drawable.img_logo)
+ setLargeIcon(image)
+ setShowWhen(true)
+ setContentTitle(remoteMessage.data["title"])
+ setContentText(currentLine)
+ style = getMessageInboxStyle(activeNotification, currentLine)
+ setContentIntent(pendingIntent)
+ setAutoCancel(true)
+ }.build()
+ }
+ }
+
+ private fun getMessageInboxStyle(
+ activeNotification: Notification?,
+ currentLine: String,
+ ): Notification.InboxStyle {
+ val previousLines =
+ activeNotification?.extras?.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)
+ ?: emptyArray()
+ val lines = previousLines.plus(currentLine)
+
+ return Notification.InboxStyle().apply {
+ lines.forEach { addLine(it) }
+ setSummaryText("${lines.size}개의 메시지")
+ }
+ }
+
+ private fun getMessageRoomPendingIntent(context: Context, id: Long): PendingIntent? {
+ val parentIntent = MainActivity.getIntent(context, MainFragmentType.MESSAGE)
+ val intent = MessageRoomActivity.getIntent(context.applicationContext, id)
+ return intent.getPendingIntent(context, id.toInt(), parentIntent)
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/type/NotificationType.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/type/NotificationType.kt
new file mode 100644
index 000000000..ced14497c
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/type/NotificationType.kt
@@ -0,0 +1,67 @@
+package com.ddangddangddang.android.notification.type
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.TaskStackBuilder
+import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
+import com.ddangddangddang.android.feature.main.MainActivity
+import com.google.firebase.messaging.RemoteMessage
+
+internal fun String.toNotificationType(): NotificationType {
+ return when (this) {
+ "message" -> MessageType
+ "bid" -> BidType
+ "question" -> QuestionType
+ "answer" -> AnswerType
+ else -> throw IllegalArgumentException("존재하지 않는 알림 타입 입니다.")
+ }
+}
+
+abstract class NotificationType {
+ abstract val tag: String
+ abstract fun createNotification(
+ context: Context,
+ id: Long,
+ remoteMessage: RemoteMessage,
+ ): Notification
+
+ fun Intent.getPendingIntent(
+ context: Context,
+ requestCode: Int,
+ parentIntent: Intent? = null,
+ ): PendingIntent? {
+ return TaskStackBuilder.create(context).run {
+ if (parentIntent != null) {
+ parentIntent.flags =
+ Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ addNextIntentWithParentStack(parentIntent)
+ addNextIntent(this@getPendingIntent)
+ } else {
+ this@getPendingIntent.flags =
+ Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ addNextIntentWithParentStack(this@getPendingIntent)
+ }
+
+ getPendingIntent(
+ requestCode,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ }
+}
+
+abstract class AuctionDetailType : NotificationType() {
+ abstract override fun createNotification(
+ context: Context,
+ id: Long,
+ remoteMessage: RemoteMessage,
+ ): Notification
+
+ fun getAuctionDetailPendingIntent(context: Context, id: Long): PendingIntent? {
+ val parentIntent = MainActivity.getIntent(context)
+ val intent = AuctionDetailActivity.getIntent(context.applicationContext, id)
+ return intent.getPendingIntent(context, id.toInt(), parentIntent)
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/type/QuestionType.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/type/QuestionType.kt
new file mode 100644
index 000000000..3f96f84cd
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/type/QuestionType.kt
@@ -0,0 +1,47 @@
+package com.ddangddangddang.android.notification.type
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.global.DdangDdangDdang
+import com.ddangddangddang.android.global.getBitmapFromUrl
+import com.ddangddangddang.android.notification.CHANNEL_ID
+import com.google.firebase.messaging.RemoteMessage
+import kotlinx.coroutines.runBlocking
+
+object QuestionType : AuctionDetailType() {
+ private val defaultImage: Bitmap by lazy {
+ BitmapFactory.decodeResource(
+ DdangDdangDdang.resources,
+ R.drawable.img_default_qna,
+ )
+ }
+ private const val SUB_TEXT = "질문이 도착했어요!"
+ override val tag: String
+ get() = "${this::class.java.simpleName}_${System.currentTimeMillis()}"
+
+ override fun createNotification(
+ context: Context,
+ id: Long,
+ remoteMessage: RemoteMessage,
+ ): Notification {
+ return runBlocking {
+ val image = runCatching {
+ getBitmapFromUrl(context, remoteMessage.data["image"] ?: "")
+ }.getOrDefault(defaultImage)
+ val pendingIntent = getAuctionDetailPendingIntent(context, id)
+
+ Notification.Builder(context.applicationContext, CHANNEL_ID).apply {
+ setSmallIcon(R.drawable.img_logo)
+ setLargeIcon(image)
+ setContentTitle(remoteMessage.data["title"])
+ setSubText(SUB_TEXT)
+ setContentText(remoteMessage.data["body"])
+ setContentIntent(pendingIntent)
+ setAutoCancel(true)
+ }.build()
+ }
+ }
+}
diff --git a/android/app/src/main/res/drawable/bg_radius_5dp.xml b/android/app/src/main/res/drawable/bg_radius_8dp.xml
similarity index 74%
rename from android/app/src/main/res/drawable/bg_radius_5dp.xml
rename to android/app/src/main/res/drawable/bg_radius_8dp.xml
index 7d74ddb0e..d8b21fa2b 100644
--- a/android/app/src/main/res/drawable/bg_radius_5dp.xml
+++ b/android/app/src/main/res/drawable/bg_radius_8dp.xml
@@ -1,4 +1,4 @@
-
-
\ No newline at end of file
+
+
diff --git a/android/app/src/main/res/drawable/ic_necessary_24.xml b/android/app/src/main/res/drawable/ic_necessary_24.xml
new file mode 100644
index 000000000..7b4e291aa
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_necessary_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/img_default_auction.png b/android/app/src/main/res/drawable/img_default_auction.png
new file mode 100644
index 000000000..780c1d8ca
Binary files /dev/null and b/android/app/src/main/res/drawable/img_default_auction.png differ
diff --git a/android/app/src/main/res/drawable/img_default_qna.png b/android/app/src/main/res/drawable/img_default_qna.png
new file mode 100644
index 000000000..17e5cfc66
Binary files /dev/null and b/android/app/src/main/res/drawable/img_default_qna.png differ
diff --git a/android/app/src/main/res/layout-land/activity_auction_detail.xml b/android/app/src/main/res/layout-land/activity_auction_detail.xml
new file mode 100644
index 000000000..5c7293a5d
--- /dev/null
+++ b/android/app/src/main/res/layout-land/activity_auction_detail.xml
@@ -0,0 +1,314 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_auction_detail.xml b/android/app/src/main/res/layout/activity_auction_detail.xml
index fed1cb02a..2117e0a63 100644
--- a/android/app/src/main/res/layout/activity_auction_detail.xml
+++ b/android/app/src/main/res/layout/activity_auction_detail.xml
@@ -15,16 +15,16 @@
+ android:background="@color/grey_50">
-
-
-
-
-
-
-
-
+ android:layout_height="wrap_content">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:background="@color/grey_50"
+ android:paddingBottom="24dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
-
+
diff --git a/android/app/src/main/res/layout/activity_message_room.xml b/android/app/src/main/res/layout/activity_message_room.xml
index f4e9eca4d..67f328e81 100644
--- a/android/app/src/main/res/layout/activity_message_room.xml
+++ b/android/app/src/main/res/layout/activity_message_room.xml
@@ -220,11 +220,12 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
+ android:maxLines="4"
android:background="@drawable/bg_stroke_gray_radius_1dp"
android:backgroundTint="@color/grey_100"
android:hint="@string/message_room_message_input_hint"
android:padding="16dp"
- android:singleLine="true"
+ android:inputType="textMultiLine"
android:text="@={viewModel.inputMessage}" />