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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +