diff --git a/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt b/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt index ad743845..9a3109e5 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt @@ -1,10 +1,10 @@ package com.nextroom.nextroom.data.datasource import com.nextroom.nextroom.data.network.ApiService +import com.nextroom.nextroom.domain.model.Mypage import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.Ticket import com.nextroom.nextroom.domain.model.UserSubscribeStatus -import com.nextroom.nextroom.domain.model.UserSubscription import com.nextroom.nextroom.domain.model.mapOnSuccess import javax.inject.Inject @@ -17,7 +17,7 @@ class SubscriptionDataSource @Inject constructor( } } - suspend fun getUserSubscription(): Result { + suspend fun getUserSubscription(): Result { return apiService.getMypageInfo().mapOnSuccess { it.data.toDomain() } } diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/response/MypageDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/response/MypageDto.kt index 70100c80..c3e87f97 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/response/MypageDto.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/response/MypageDto.kt @@ -1,20 +1,26 @@ package com.nextroom.nextroom.data.network.response import com.google.gson.annotations.SerializedName -import com.nextroom.nextroom.domain.model.SubscribeItem -import com.nextroom.nextroom.domain.model.UserSubscription +import com.nextroom.nextroom.domain.model.Mypage +import com.nextroom.nextroom.domain.model.SubscribeStatus +// TODO JH: 이름 변경 고려해보기 data class MypageDto( @SerializedName("id") val id: String, - @SerializedName("subStatus") val subsName: String, // LARGE - @SerializedName("createdAt") val createdAt: String, // 2023-10-24 02:29:57 - @SerializedName("expiryDate") val expiryDate: String, // 2023-11-23 + @SerializedName("name") val name: String, // xx이스케이프 xx점 + @SerializedName("status") val status: String, // FREE or SUBSCRIPTION + @SerializedName("startDate") val startDate: String?, // 2023-10-24 02:29:57 + @SerializedName("expiryDate") val expiryDate: String?, // 2023-11-23 + @SerializedName("createdAt") val createdAt: String, // 2023-11-23 ) { - fun toDomain(): UserSubscription { - return UserSubscription( - type = SubscribeItem(id = id, name = subsName), - createdAt = createdAt, + fun toDomain(): Mypage { + return Mypage( + id = id, + name = name, + status = SubscribeStatus.ofValue(status), + startDate = startDate, expiryDate = expiryDate, + createdAt = createdAt, ) } } diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/response/TicketDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/response/TicketDto.kt index 5b36f7c9..5f1efaf7 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/response/TicketDto.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/response/TicketDto.kt @@ -5,18 +5,26 @@ import com.nextroom.nextroom.domain.model.Ticket data class TicketDto( @SerializedName("id") val id: String, - @SerializedName("plan") val plan: String, + @SerializedName("subscriptionProductId") val subscriptionProductId: String, + @SerializedName("planId") val planId: String, + @SerializedName("productName") val productName: String, @SerializedName("description") val description: String, + @SerializedName("subDescription") val subDescription: String, @SerializedName("originPrice") val originPrice: Int?, @SerializedName("sellPrice") val sellPrice: Int, + @SerializedName("discountRate") val discountRate: Int, ) { fun toDomain(): Ticket { return Ticket( id = id, - plan = plan, + subscriptionProductId = subscriptionProductId, + planId = planId, + productName = productName, description = description, + subDescription = subDescription, originPrice = originPrice, sellPrice = sellPrice, + discountRate = discountRate, ) } } diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/response/UserSubscriptionStatusDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/response/UserSubscriptionStatusDto.kt index 697ca959..b047a3b7 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/response/UserSubscriptionStatusDto.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/response/UserSubscriptionStatusDto.kt @@ -16,12 +16,13 @@ data class UserSubscriptionStatusDto( ) { fun toDomain(): UserSubscribeStatus { val subsStatus = when (status.trim().uppercase()) { - "FREE" -> SubscribeStatus.Free - "HOLD" -> SubscribeStatus.Hold - "EXPIRATION" -> SubscribeStatus.Expiration - "SUBSCRIPTION" -> SubscribeStatus.Subscription - "SUBSCRIPTION_EXPIRATION" -> SubscribeStatus.SubscriptionExpiration - else -> SubscribeStatus.None +// "FREE" -> SubscribeStatus.Free +// "HOLD" -> SubscribeStatus.Hold +// "EXPIRATION" -> SubscribeStatus.Expiration +// "SUBSCRIPTION" -> SubscribeStatus.Subscription +// "SUBSCRIPTION_EXPIRATION" -> SubscribeStatus.SubscriptionExpiration +// else -> SubscribeStatus.None + else -> SubscribeStatus.Default } return UserSubscribeStatus( subscriptionId = id, diff --git a/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt b/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt index f8f009fc..af3c910b 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt @@ -6,9 +6,9 @@ import com.nextroom.nextroom.data.datasource.SubscriptionDataSource import com.nextroom.nextroom.data.datasource.TokenDataSource import com.nextroom.nextroom.data.datasource.UserDataSource import com.nextroom.nextroom.domain.model.LoginInfo +import com.nextroom.nextroom.domain.model.Mypage import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.UserSubscribeStatus -import com.nextroom.nextroom.domain.model.UserSubscription import com.nextroom.nextroom.domain.model.onSuccess import com.nextroom.nextroom.domain.repository.AdminRepository import kotlinx.coroutines.flow.Flow @@ -26,6 +26,9 @@ class AdminRepositoryImpl @Inject constructor( override val shopName: Flow = settingDataSource.shopName + // TODO: 구독 서비스 정규 오픈시 삭제 + var isDeveloperMode = false + override suspend fun login(adminCode: String, password: String): Result { return authDataSource.login(adminCode, password).onSuccess { settingDataSource.saveAdminInfo(adminCode = it.adminCode, shopName = it.shopName) @@ -51,7 +54,14 @@ class AdminRepositoryImpl @Inject constructor( return subscriptionDataSource.getUserSubscriptionStatus() } - override suspend fun getUserSubscribe(): Result { + override suspend fun getUserSubscribe(): Result { return subscriptionDataSource.getUserSubscription() } + + // TODO: 구독 서비스 정규 오픈시 삭제 + override fun setDeveloperMode() { + isDeveloperMode = true + } + + override fun getIsDeveloperMode() = isDeveloperMode } diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/model/Mypage.kt b/domain/src/main/java/com/nextroom/nextroom/domain/model/Mypage.kt new file mode 100644 index 00000000..014389c4 --- /dev/null +++ b/domain/src/main/java/com/nextroom/nextroom/domain/model/Mypage.kt @@ -0,0 +1,10 @@ +package com.nextroom.nextroom.domain.model + +data class Mypage( + val id: String, + val name: String, + val status: SubscribeStatus, + val startDate: String?, + val expiryDate: String?, + val createdAt: String, +) \ No newline at end of file diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/model/Subscribe.kt b/domain/src/main/java/com/nextroom/nextroom/domain/model/Subscribe.kt index 42d6a355..501c4b03 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/model/Subscribe.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/model/Subscribe.kt @@ -31,19 +31,22 @@ data class SubscribeItem( */ data class UserSubscribeStatus( val subscriptionId: Long = 0L, - val subscribeStatus: SubscribeStatus = SubscribeStatus.None, + val subscribeStatus: SubscribeStatus = SubscribeStatus.Default, val expiryDate: String = "", val createdAt: String = "", ) /** - * @property None 아무것도 아닌 상태 (현재 사용하지 않음) - * @property Free 무료 체험 - * @property Hold 유예 기간 (무료 체험 끝) - * @property Expiration 유예 기간 만료 - * @property Subscription 구독 - * @property SubscriptionExpiration 구독 만료 + * @property Default 아무것도 구독하지 않은 상태 + * @property Subscribed 구독 중 상태 */ -enum class SubscribeStatus { - None, Free, Hold, Expiration, Subscription, SubscriptionExpiration -} +enum class SubscribeStatus(val value: String) { + Default("FREE"), + Subscribed("SUBSCRIPTION"); + + companion object { + fun ofValue(value: String): SubscribeStatus { + return entries.find { it.value.uppercase() == value.uppercase() } ?: Default + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/model/Ticket.kt b/domain/src/main/java/com/nextroom/nextroom/domain/model/Ticket.kt index 4ae6d017..5d4d2351 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/model/Ticket.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/model/Ticket.kt @@ -2,8 +2,12 @@ package com.nextroom.nextroom.domain.model data class Ticket( val id: String, - val plan: String, + val subscriptionProductId: String, + val planId: String, + val productName: String, val description: String, + val subDescription: String, val originPrice: Int?, val sellPrice: Int, + val discountRate: Int, ) diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt b/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt index 3b6eb90f..a51bb0b1 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt @@ -1,9 +1,9 @@ package com.nextroom.nextroom.domain.repository import com.nextroom.nextroom.domain.model.LoginInfo +import com.nextroom.nextroom.domain.model.Mypage import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.UserSubscribeStatus -import com.nextroom.nextroom.domain.model.UserSubscription import kotlinx.coroutines.flow.Flow interface AdminRepository { @@ -19,5 +19,9 @@ interface AdminRepository { suspend fun resign(): Result suspend fun verifyAdminCode(code: String): Boolean suspend fun getUserSubscribeStatus(): Result - suspend fun getUserSubscribe(): Result + suspend fun getUserSubscribe(): Result + + // TODO: 구독 서비스 정규 오픈시 삭제 + fun setDeveloperMode() + fun getIsDeveloperMode(): Boolean } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/NRLoading.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/NRLoading.kt new file mode 100644 index 00000000..91d8a57f --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/NRLoading.kt @@ -0,0 +1,45 @@ +package com.nextroom.nextroom.presentation.common + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ProgressBar +import androidx.core.content.ContextCompat + +class NRLoading @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + + init { + // 레이아웃의 크기를 부모의 크기로 설정 + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + + // 배경을 투명하게 설정 + setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent)) + + // ProgressBar를 가운데에 배치 + val progressBar = ProgressBar(context).apply { + layoutParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + ).apply { + // 가운데 정렬 + gravity = android.view.Gravity.CENTER + } + } + + // 로딩중에 하위 뷰 터치 막기 + setOnTouchListener { _, _ -> + true + } + + // ProgressBar를 이 뷰에 추가 + addView(progressBar) + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/Constants.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/Constants.kt index 48af4132..f392bdad 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/Constants.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/Constants.kt @@ -1,7 +1,5 @@ package com.nextroom.nextroom.presentation.ui object Constants { - const val MINI_PRODUCT = "mini_subscription" - const val MEDIUM_PRODUCT = "medium_subscription" - const val LARGE_PRODUCT = "large_subscription" + const val MEMBERSHIP_PRODUCT = "membership_subscription" } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/MainActivity.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/MainActivity.kt index 406cda2d..92f31957 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/MainActivity.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/MainActivity.kt @@ -15,9 +15,12 @@ import com.nextroom.nextroom.presentation.common.NRDialog import com.nextroom.nextroom.presentation.databinding.ActivityMainBinding import com.nextroom.nextroom.presentation.extension.repeatOn import com.nextroom.nextroom.presentation.extension.repeatOnStarted +import com.nextroom.nextroom.presentation.ui.billing.BillingViewModel +import com.nextroom.nextroom.presentation.util.BillingClientLifecycle import com.nextroom.nextroom.presentation.util.WindowInsetsManager import com.nextroom.nextroom.presentation.util.WindowInsetsManagerImpl import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : @@ -26,10 +29,10 @@ class MainActivity : private lateinit var binding: ActivityMainBinding private val viewModel: MainViewModel by viewModels() -// private val billingViewModel: BillingViewModel by viewModels() + private val billingViewModel: BillingViewModel by viewModels() -// @Inject -// lateinit var billingClientLifecycle: BillingClientLifecycle + @Inject + lateinit var billingClientLifecycle: BillingClientLifecycle override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge( @@ -42,7 +45,7 @@ class MainActivity : binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) -// lifecycle.addObserver(billingClientLifecycle) + lifecycle.addObserver(billingClientLifecycle) repeatOnStarted { viewModel.event.collect(::observe) @@ -52,11 +55,11 @@ class MainActivity : if (!loggedIn) viewModel.logout() } } -// repeatOnStarted { -// billingViewModel.buyEvent.collect { -// billingClientLifecycle.launchBillingFlow(this@MainActivity, it) -// } -// } + repeatOnStarted { + billingViewModel.buyEvent.collect { + billingClientLifecycle.launchBillingFlow(this@MainActivity, it) + } + } } private fun observe(event: MainEvent) { diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainEvent.kt index 7ade6b52..11083b3f 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainEvent.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainEvent.kt @@ -4,5 +4,4 @@ sealed interface AdminMainEvent { data object NetworkError : AdminMainEvent data object UnknownError : AdminMainEvent data class ClientError(val message: String) : AdminMainEvent - data object OnResign : AdminMainEvent } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainFragment.kt index bc548a13..6f67baec 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainFragment.kt @@ -5,16 +5,16 @@ import android.os.Bundle import android.view.View import androidx.activity.OnBackPressedCallback import androidx.core.view.isVisible -import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.domain.repository.StatisticsRepository import com.nextroom.nextroom.presentation.R import com.nextroom.nextroom.presentation.base.BaseFragment -import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog import com.nextroom.nextroom.presentation.databinding.FragmentAdminMainBinding import com.nextroom.nextroom.presentation.extension.addMargin import com.nextroom.nextroom.presentation.extension.safeNavigate +import com.nextroom.nextroom.presentation.extension.setOnLongClickListener import com.nextroom.nextroom.presentation.extension.snackbar import com.nextroom.nextroom.presentation.extension.statusBarHeight import com.nextroom.nextroom.presentation.extension.toast @@ -39,6 +39,8 @@ class AdminMainFragment : onClickUpdate = viewModel::updateTheme, ) } + private val state: AdminMainState + get() = viewModel.container.stateFlow.value override fun onAttach(context: Context) { super.onAttach(context) @@ -54,7 +56,6 @@ class AdminMainFragment : super.onViewCreated(view, savedInstanceState) initViews() - setFragmentResultListeners() viewModel.observe(viewLifecycleOwner, state = ::render, sideEffect = ::handleEvent) } @@ -76,61 +77,34 @@ class AdminMainFragment : private fun initViews() = with(binding) { updateSystemPadding(statusBar = false, navigationBar = true) + ivMyButton.addMargin(top = requireContext().statusBarHeight) + rvThemes.adapter = adapter -// tvPurchaseTicketButton.setOnClickListener { -// goToPurchase() -// } -// ivMyButton.setOnClickListener { -// goToMyPage() -// } -// tvLogoutButton.apply { -// addMargin(top = requireContext().statusBarHeight) -// setOnClickListener { logout() } -// } - srlTheme.setOnRefreshListener { - viewModel.loadData() + tvPurchaseButton.setOnClickListener { + goToPurchase() } - tvResignButton.addMargin(top = requireContext().statusBarHeight) - tvResignButton.setOnClickListener { - AdminMainFragmentDirections - .actionGlobalNrTwoButtonDialog( - NRTwoButtonDialog.NRTwoButtonArgument( - title = getString(R.string.resign_dialog_title), - message = getString(R.string.resign_dialog_message), - posBtnText = getString(R.string.resign), - negBtnText = getString(R.string.dialog_no), - dialogKey = REQUEST_KEY_RESIGN, - ) - ) - .also { findNavController().safeNavigate(it) } + ivMyButton.setOnClickListener { + goToMyPage() } - } - private fun setFragmentResultListeners() { - setFragmentResultListener(REQUEST_KEY_RESIGN) { _, _ -> - viewModel.resign() + srlTheme.setOnRefreshListener { + viewModel.loadData() + } + // TODO: 구독 서비스 정규 오픈시 삭제 + tvSecretButton.setOnLongClickListener(1000L) { + toast("개발자 모드 활성화") + viewModel.setDeveloperMode() } } private fun render(state: AdminMainState) = with(binding) { if (state.loading) return@with -// tvPurchaseTicketButton.isVisible = state.userSubscribeStatus.subscribeStatus != SubscribeStatus.Subscription -// when (state.userSubscribeStatus.subscribeStatus) { -// SubscribeStatus.Expiration -> logout() -// SubscribeStatus.Hold, SubscribeStatus.SubscriptionExpiration -> goToPurchase(state.userSubscribeStatus.subscribeStatus) -// SubscribeStatus.Free -> { -// if (viewModel.isFirstLaunchOfDay) { // 하루 최초 한 번 다이얼로그 표시 -// state.calculateDday().let { dday -> -// if (dday >= 0) showDialog(dday) -// } -// } -// } -// -// SubscribeStatus.None, SubscribeStatus.Subscription -> Unit -// } + // TODO: 구독 서비스 정규 오픈시 삭제 + tvPurchaseButton.isVisible = + (viewModel.getIsDeveloperMode() && state.subscribeStatus != SubscribeStatus.Subscribed) srlTheme.isRefreshing = false - tvShopName.text = state.showName + tvShopName.text = state.shopName llEmptyThemeGuide.isVisible = state.themes.isEmpty() adapter.submitList(state.themes) } @@ -140,14 +114,13 @@ class AdminMainFragment : is AdminMainEvent.NetworkError -> snackbar(R.string.error_network) is AdminMainEvent.UnknownError -> snackbar(R.string.error_something) is AdminMainEvent.ClientError -> snackbar(event.message) - is AdminMainEvent.OnResign -> toast(R.string.resign_success_message) } } - /*private fun goToPurchase(subscribeStatus: SubscribeStatus = state.userSubscribeStatus.subscribeStatus) { - val action = AdminMainFragmentDirections.actionAdminMainFragmentToPurchaseFragment(subscribeStatus) + private fun goToPurchase() { + val action = AdminMainFragmentDirections.actionAdminMainFragmentToPurchaseFragment() findNavController().safeNavigate(action) - }*/ + } private fun goToMyPage() { val action = AdminMainFragmentDirections.actionAdminMainFragmentToMypageFragment() @@ -160,24 +133,6 @@ class AdminMainFragment : findNavController().safeNavigate(action) } - /*private fun showDialog(dDay: Int) { - NRImageDialog.Builder(requireContext()) - .setTitle(getString(R.string.dialog_free_plan_title, dDay)) - .setMessage(getString(R.string.dialog_free_plan_message)) - .setImage(R.drawable.ticket) - .setNegativeButton(getString(R.string.dialog_close)) { dialog, _ -> - dialog.dismiss() - } - .setPositiveButton(getString(R.string.dialog_subscribe_button)) { _, _ -> - goToPurchase() - } - .show(childFragmentManager) - }*/ - - private fun logout() { - viewModel.logout() - } - override fun onDestroyView() { binding.rvThemes.adapter = null super.onDestroyView() @@ -187,8 +142,4 @@ class AdminMainFragment : super.onDetach() backCallback.remove() } - - companion object { - const val REQUEST_KEY_RESIGN = "REQUEST_KEY_RESIGN" - } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainState.kt index 4583b37c..35ce9ba4 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainState.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainState.kt @@ -1,19 +1,11 @@ package com.nextroom.nextroom.presentation.ui.adminmain +import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.presentation.model.ThemeInfoPresentation data class AdminMainState( val loading: Boolean = false, -// val userSubscribeStatus: UserSubscribeStatus = UserSubscribeStatus(), - val showName: String = "", + val subscribeStatus: SubscribeStatus = SubscribeStatus.Default, + val shopName: String = "", val themes: List = emptyList(), -) { - /*private val dateTimeUtil = DateTimeUtil() - - fun calculateDday(): Int { - return when (userSubscribeStatus.subscribeStatus) { - SubscribeStatus.Free -> dateTimeUtil.stringToDate(userSubscribeStatus.expiryDate, "yyyy.MM.dd")?.calculateDday() ?: -1 - else -> -1 - } - }*/ -} +) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainViewModel.kt index 9ae688f5..fd1744ab 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/adminmain/AdminMainViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.onFailure import com.nextroom.nextroom.domain.model.onSuccess +import com.nextroom.nextroom.domain.model.suspendOnSuccess import com.nextroom.nextroom.domain.repository.AdminRepository import com.nextroom.nextroom.domain.repository.DataStoreRepository import com.nextroom.nextroom.domain.repository.HintRepository @@ -32,9 +33,6 @@ class AdminMainViewModel @Inject constructor( override val container: Container = container(AdminMainState(loading = true)) - val isFirstLaunchOfDay: Boolean - get() = dataStoreRepository.isFirstInitOfDay - init { loadData() @@ -74,24 +72,10 @@ class AdminMainViewModel @Inject constructor( } } - fun logout() { - viewModelScope.launch { adminRepository.logout() } - } - - fun resign() = intent { - viewModelScope.launch { - adminRepository.resign().onSuccess { - postSideEffect(AdminMainEvent.OnResign) - }.onFailure { - postSideEffect(AdminMainEvent.UnknownError) - } - } - } - fun loadData() = intent { reduce { state.copy(loading = true) } - /*adminRepository.getUserSubscribeStatus().suspendOnSuccess { - reduce { state.copy(userSubscribeStatus = it) } + adminRepository.getUserSubscribe().suspendOnSuccess { + reduce { state.copy(subscribeStatus = it.status) } themeRepository.getThemes().onSuccess { updateThemes( it.map { themeInfo -> @@ -100,7 +84,7 @@ class AdminMainViewModel @Inject constructor( }, ) } - }*/ + } themeRepository.getThemes().onSuccess { updateThemes( it.map { themeInfo -> @@ -113,13 +97,20 @@ class AdminMainViewModel @Inject constructor( } private fun updateShopInfo(shopName: String) = intent { - reduce { state.copy(showName = shopName) } + reduce { state.copy(shopName = shopName) } } private fun updateThemes(themes: List) = intent { reduce { state.copy(themes = themes) } } + // TODO: 구독 서비스 정규 오픈시 삭제 + fun setDeveloperMode() { + adminRepository.setDeveloperMode() + } + + fun getIsDeveloperMode() = adminRepository.getIsDeveloperMode() + private fun handleError(error: Result.Failure) = intent { when (error) { is Result.Failure.NetworkError -> postSideEffect(AdminMainEvent.NetworkError) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/billing/BillingViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/billing/BillingViewModel.kt index 81163503..0b3bf003 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/billing/BillingViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/billing/BillingViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase +import com.nextroom.nextroom.domain.model.onFailure +import com.nextroom.nextroom.domain.model.onSuccess +import com.nextroom.nextroom.domain.repository.BillingRepository import com.nextroom.nextroom.presentation.ui.Constants import com.nextroom.nextroom.presentation.util.BillingClientLifecycle import dagger.hilt.android.lifecycle.HiltViewModel @@ -18,15 +21,14 @@ import javax.inject.Inject class BillingViewModel @Inject constructor( billingClientLifecycle: BillingClientLifecycle, + billingRepository: BillingRepository, ) : ViewModel() { // 사용자의 현재 구독 상품 구매 정보 private val purchases = billingClientLifecycle.subscriptionPurchases // 콘솔에 등록된 상품들 정보 - private val largeSubProductWithProductDetails = billingClientLifecycle.largeSubProductWithProductDetails - private val mediumSubProductWithProductDetails = billingClientLifecycle.mediumSubProductWithProductDetails - private val miniSubProductWithProductDetails = billingClientLifecycle.miniSubProductWithProductDetails + private val membershipProductWithProductDetails = billingClientLifecycle.membershipProductWithProductDetails private val _buyEvent = MutableSharedFlow() val buyEvent = _buyEvent.asSharedFlow() @@ -46,8 +48,14 @@ class BillingViewModel purchases.collect { it.forEach { purchase -> if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - // TODO JH: 서버에서 ack 로직 구현 완료시 제거 + purchaseToken 보내는 API 호출 - billingClientLifecycle.acknowledgePurchase(purchase.purchaseToken) + billingRepository + .postPurchaseToken(purchase.purchaseToken) + .onSuccess { + _uiEvent.emit(BillingEvent.PurchaseAcknowledged) + } + .onFailure { + _uiEvent.emit(BillingEvent.PurchaseFailed(purchaseState = purchase.purchaseState)) + } } else { _uiEvent.emit(BillingEvent.PurchaseFailed(purchaseState = purchase.purchaseState)) } @@ -186,9 +194,7 @@ class BillingViewModel } when (productId) { - Constants.LARGE_PRODUCT -> largeSubProductWithProductDetails.value - Constants.MEDIUM_PRODUCT -> mediumSubProductWithProductDetails.value - Constants.MINI_PRODUCT -> miniSubProductWithProductDetails.value + Constants.MEMBERSHIP_PRODUCT -> membershipProductWithProductDetails.value else -> null }?.also { productDetails -> productDetails.subscriptionOfferDetails?.let { offerDetailsList -> @@ -212,9 +218,7 @@ class BillingViewModel productDetails: ProductDetails, ) { val currentSubscriptionPurchaseCount = purchases.value.count { - it.products.contains(Constants.MINI_PRODUCT) || - it.products.contains(Constants.MEDIUM_PRODUCT) || - it.products.contains(Constants.LARGE_PRODUCT) + it.products.contains(Constants.MEMBERSHIP_PRODUCT) } if (currentSubscriptionPurchaseCount > EXPECTED_SUBSCRIPTION_PURCHASE_LIST_SIZE) { Timber.e("There are more than one subscription purchases on the device.") @@ -227,9 +231,7 @@ class BillingViewModel } val oldToken = purchases.value.filter { - it.products.contains(Constants.MINI_PRODUCT) || - it.products.contains(Constants.MEDIUM_PRODUCT) || - it.products.contains(Constants.LARGE_PRODUCT) + it.products.contains(Constants.MEMBERSHIP_PRODUCT) }.firstOrNull { it.purchaseToken.isNotEmpty() }?.purchaseToken ?: "" val billingParams: BillingFlowParams = if (upDowngrade) { diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageFragment.kt index fe19b51e..61c282aa 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageFragment.kt @@ -3,56 +3,113 @@ package com.nextroom.nextroom.presentation.ui.mypage import android.os.Bundle import android.view.View import androidx.core.view.isVisible +import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.presentation.R import com.nextroom.nextroom.presentation.base.BaseFragment +import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog import com.nextroom.nextroom.presentation.databinding.FragmentMypageBinding +import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate +import com.nextroom.nextroom.presentation.extension.snackbar +import com.nextroom.nextroom.presentation.extension.toast import dagger.hilt.android.AndroidEntryPoint -import org.orbitmvi.orbit.viewmodel.observe +import kotlinx.coroutines.launch @AndroidEntryPoint class MypageFragment : BaseFragment(FragmentMypageBinding::inflate) { private val viewModel: MypageViewModel by viewModels() - private val state: MypageState - get() = viewModel.container.stateFlow.value override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initViews() - viewModel.observe(viewLifecycleOwner, state = ::render) + initListeners() + initObserve() + setFragmentResultListeners() } private fun initViews() = with(binding) { tbMypage.apply { tvButton.isVisible = false tvTitle.text = getString(R.string.mypage_title) - ivBack.setOnClickListener { findNavController().popBackStack() } } - tvPurchaseTicketButton.setOnClickListener { goToPurchase(state.userSubscribeStatus.subscribeStatus) } + } + + private fun initListeners() = with(binding) { + tbMypage.ivBack.setOnClickListener { findNavController().popBackStack() } tvLogoutButton.setOnClickListener { viewModel.logout() } + tvResignButton.setOnClickListener { showConfirmResignDialog() } + clSubscribe.setOnClickListener { + (viewModel.uiState.value as? MypageViewModel.UiState.Loaded)?.let { loaded -> + when (loaded.status) { + SubscribeStatus.Default -> goToPurchase() + SubscribeStatus.Subscribed -> goToSubscriptionInfo() + } + } + } } - private fun render(state: MypageState) = with(binding) { - pbLoading.isVisible = state.loading - groupRemoteData.isVisible = !state.loading + private fun initObserve() { + viewLifecycleOwner.repeatOnStarted { + launch { + viewModel.uiState.collect { state -> + when (state) { + MypageViewModel.UiState.Failure -> snackbar(R.string.error_something) + is MypageViewModel.UiState.Loaded -> { + binding.tvShopName.text = state.shopName + binding.pbLoading.isVisible = false + binding.clSubscribe.isVisible = viewModel.getIsDeveloperMode() // TODO: 구독 서비스 정규 오픈시 삭제 + } - tvShopName.text = state.shopName - tvPurchaseTicketButton.text = if (state.userSubscribeStatus.subscribeStatus == SubscribeStatus.Subscription) { - getString(R.string.purchase_change_ticket) - } else { - getString(R.string.purchase_ticket) + MypageViewModel.UiState.Loading -> binding.pbLoading.isVisible = true + } + } + } + launch { + viewModel.uiEvent.collect { event -> + when (event) { + MypageViewModel.UiEvent.ResignFail -> snackbar(R.string.error_something) + MypageViewModel.UiEvent.ResignSuccess -> toast(R.string.resign_success_message) + } + } + } } - tvSubsName.text = state.userSubscription?.type?.name ?: "" - tvSubsPeriod.text = state.period } - private fun goToPurchase(subscribeStatus: SubscribeStatus) { - val action = MypageFragmentDirections.actionMypageFragmentToPurchaseFragment(subscribeStatus) + private fun setFragmentResultListeners() { + setFragmentResultListener(REQUEST_KEY_RESIGN) { _, _ -> + viewModel.resign() + } + } + + private fun goToPurchase() { + val action = MypageFragmentDirections.actionMypageFragmentToPurchaseFragment() findNavController().safeNavigate(action) } + + private fun goToSubscriptionInfo() { + val action = MypageFragmentDirections.actionMypageFragmentToSubscriptionFragment() + findNavController().safeNavigate(action) + } + + private fun showConfirmResignDialog() { + MypageFragmentDirections + .actionGlobalNrTwoButtonDialog( + NRTwoButtonDialog.NRTwoButtonArgument( + title = getString(R.string.resign_dialog_title), + message = getString(R.string.resign_dialog_message), + posBtnText = getString(R.string.resign), + negBtnText = getString(R.string.dialog_no), + dialogKey = REQUEST_KEY_RESIGN, + ), + ).also { findNavController().safeNavigate(it) } + } + + companion object { + const val REQUEST_KEY_RESIGN = "REQUEST_KEY_RESIGN" + } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageViewModel.kt index 1a50ea76..81cc44a2 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageViewModel.kt @@ -1,54 +1,97 @@ package com.nextroom.nextroom.presentation.ui.mypage +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.domain.model.onFailure import com.nextroom.nextroom.domain.model.onFinally -import com.nextroom.nextroom.domain.model.suspendConcatMap +import com.nextroom.nextroom.domain.model.onSuccess import com.nextroom.nextroom.domain.repository.AdminRepository -import com.nextroom.nextroom.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import org.orbitmvi.orbit.Container -import org.orbitmvi.orbit.syntax.simple.intent -import org.orbitmvi.orbit.syntax.simple.reduce -import org.orbitmvi.orbit.viewmodel.container +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MypageViewModel @Inject constructor( private val adminRepository: AdminRepository, -) : BaseViewModel() { +) : ViewModel() { - override val container: Container = container(MypageState(loading = true)) + private val _myInfo = MutableStateFlow(UiState.Loading) + private val _isResignLoading = MutableStateFlow(false) + + val uiState = combine( + _myInfo, + _isResignLoading, + ) { myInfo, isResignLoading -> + if (myInfo is UiState.Loading || isResignLoading) { + UiState.Loading + } else { + myInfo + } + }.stateIn(viewModelScope, SharingStarted.Lazily, UiState.Loading) + + private val _uiEvent = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() init { - fetchShopName() - fetchSubsInfo() + fetchMyInfo() } - fun logout() = intent { - adminRepository.logout() + fun logout() { + viewModelScope.launch { + adminRepository.logout() + } } - private fun fetchShopName() = intent { - adminRepository.shopName.collect { - reduce { state.copy(shopName = it) } + private fun fetchMyInfo() { + viewModelScope.launch { + adminRepository.getUserSubscribe().onSuccess { mypage -> + UiState.Loaded( + shopName = mypage.name, + status = mypage.status, + ).also { + _myInfo.emit(it) + } + }.onFailure { + _myInfo.emit(UiState.Failure) + } } } - private fun fetchSubsInfo() = intent { - reduce { state.copy(loading = true) } - adminRepository.getUserSubscribeStatus().suspendConcatMap( - other = adminRepository.getUserSubscribe(), - ) { subsStatus, mypageInfo -> - reduce { - state.copy( - loading = false, - userSubscribeStatus = subsStatus, - userSubscription = mypageInfo, - ) - } - }.onFinally { - reduce { - state.copy(loading = false) + fun resign() { + viewModelScope.launch { + _isResignLoading.emit(true) + adminRepository.resign().onSuccess { + _uiEvent.emit(UiEvent.ResignSuccess) + }.onFailure { + _uiEvent.emit(UiEvent.ResignFail) + }.onFinally { + _isResignLoading.emit(false) } } } + + // TODO: 구독 서비스 정규 오픈시 삭제 + fun getIsDeveloperMode() = adminRepository.getIsDeveloperMode() + + sealed interface UiState { + data object Loading : UiState + data class Loaded( + val shopName: String, + val status: SubscribeStatus, + ) : UiState + + data object Failure : UiState + } + + sealed interface UiEvent { + data object ResignSuccess : UiEvent + data object ResignFail : UiEvent + } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/SubscriptionInfoFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/SubscriptionInfoFragment.kt new file mode 100644 index 00000000..6a42cb98 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/SubscriptionInfoFragment.kt @@ -0,0 +1,90 @@ +package com.nextroom.nextroom.presentation.ui.mypage + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.BaseFragment +import com.nextroom.nextroom.presentation.databinding.FragmentSubscriptionInfoBinding +import com.nextroom.nextroom.presentation.extension.repeatOnStarted +import com.nextroom.nextroom.presentation.extension.snackbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Locale + +@AndroidEntryPoint +class SubscriptionInfoFragment : + BaseFragment(FragmentSubscriptionInfoBinding::inflate) { + private val viewModel: SubscriptionInfoViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initViews() + initListeners() + initObserve() + } + + private fun initViews() = with(binding) { + tbSubscriptionInfo.apply { + tvButton.isVisible = false + tvTitle.text = getString(R.string.subscription_info) + } + } + + private fun initObserve() { + viewLifecycleOwner.repeatOnStarted { + launch { + viewModel.uiState.collect { state -> + when (state) { + SubscriptionInfoViewModel.UiState.Failure -> snackbar(R.string.error_something) + is SubscriptionInfoViewModel.UiState.Loaded -> { + binding.tvSubscriptionStatus.text = when (state.subscribeStatus) { + SubscribeStatus.Default -> getString(R.string.ticket_not_subscribe) + SubscribeStatus.Subscribed -> getString(R.string.ticket_subscribing) + } + binding.tvSubscriptionPeriod.text = getSubscriptionPeriod(state.startDate, state.endDate) + binding.pbLoading.isVisible = false + } + + SubscriptionInfoViewModel.UiState.Loading -> binding.pbLoading.isVisible = true + } + } + } + } + } + + private fun getSubscriptionPeriod(startDate: String?, endDate: String?): String { + return try { + requireNotNull(startDate) + requireNotNull(endDate) + + val inputFormat = SimpleDateFormat(apiDateFormatter, Locale.getDefault()) + val outputFormat = SimpleDateFormat(uiDateFormatter, Locale.getDefault()) + + val start = inputFormat.parse(startDate) + val end = inputFormat.parse(endDate) + + String.format( + getString(R.string.date_range_format), + outputFormat.format(start), + outputFormat.format(end), + ) + } catch (e: Exception) { + "" + } + } + + private fun initListeners() { + binding.tbSubscriptionInfo.ivBack.setOnClickListener { findNavController().popBackStack() } + } + + companion object { + const val apiDateFormatter = "yyyy-MM-dd" + const val uiDateFormatter = "yyyy.M.d" + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/SubscriptionInfoViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/SubscriptionInfoViewModel.kt new file mode 100644 index 00000000..61e6badd --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/SubscriptionInfoViewModel.kt @@ -0,0 +1,53 @@ +package com.nextroom.nextroom.presentation.ui.mypage + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.domain.model.onFailure +import com.nextroom.nextroom.domain.model.onSuccess +import com.nextroom.nextroom.domain.repository.AdminRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SubscriptionInfoViewModel @Inject constructor( + private val adminRepository: AdminRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState = _uiState.asStateFlow() + + init { + fetchMyInfo() + } + + private fun fetchMyInfo() { + viewModelScope.launch { + adminRepository.getUserSubscribe().onSuccess { mypage -> + UiState.Loaded( + subscribeStatus = mypage.status, + startDate = mypage.startDate, + endDate = mypage.expiryDate, + ).also { + _uiState.emit(it) + } + }.onFailure { + _uiState.emit(UiState.Failure) + } + } + } + + sealed interface UiState { + data object Loading : UiState + data class Loaded( + val subscribeStatus: SubscribeStatus, + val startDate: String?, + val endDate: String?, + ) : UiState + + data object Failure : UiState + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseEvent.kt deleted file mode 100644 index 82af0f34..00000000 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseEvent.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.nextroom.nextroom.presentation.ui.purchase - -sealed interface PurchaseEvent { - data class StartPurchase( - val productId: String, - val tag: String, - val upDowngrade: Boolean, - ) : PurchaseEvent -} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseFragment.kt index 6bf22eb3..63e889d8 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseFragment.kt @@ -2,82 +2,54 @@ package com.nextroom.nextroom.presentation.ui.purchase import android.os.Bundle import android.view.View -import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.presentation.R import com.nextroom.nextroom.presentation.base.BaseFragment -import com.nextroom.nextroom.presentation.common.LinearSpaceDecoration import com.nextroom.nextroom.presentation.databinding.FragmentPurchaseBinding -import com.nextroom.nextroom.presentation.extension.dp import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate +import com.nextroom.nextroom.presentation.extension.snackbar +import com.nextroom.nextroom.presentation.extension.strikeThrow import com.nextroom.nextroom.presentation.extension.toast import com.nextroom.nextroom.presentation.ui.billing.BillingEvent import com.nextroom.nextroom.presentation.ui.billing.BillingViewModel import dagger.hilt.android.AndroidEntryPoint -import org.orbitmvi.orbit.viewmodel.observe +import kotlinx.coroutines.launch @AndroidEntryPoint class PurchaseFragment : BaseFragment(FragmentPurchaseBinding::inflate) { private val viewModel: PurchaseViewModel by viewModels() private val billingViewModel: BillingViewModel by activityViewModels() - private val adapter: TicketAdapter by lazy { TicketAdapter(viewModel::startPurchase) } - private val spacer: LinearSpaceDecoration = LinearSpaceDecoration(spaceBetween = 12.dp) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initViews() - viewModel.observe(viewLifecycleOwner, state = ::render, sideEffect = ::handleEvent) + initListeners() initObserve() } private fun initViews() = with(binding) { tbPurchase.apply { - root.isVisible = false + tvTitle.text = getString(R.string.purchase_ticket) tvButton.isVisible = false - ivBack.setOnClickListener { findNavController().popBackStack() } - } - rvSubscribes.apply { - adapter = this@PurchaseFragment.adapter - addItemDecoration(spacer) } } - private fun render(state: PurchaseState) = with(binding) { - tbPurchase.root.isInvisible = state.subscribeStatus in listOf(SubscribeStatus.Hold, SubscribeStatus.SubscriptionExpiration) - tbPurchase.tvTitle.text = when (state.subscribeStatus) { - SubscribeStatus.Free -> getString(R.string.purchase_ticket) - SubscribeStatus.Subscription -> getString(R.string.purchase_change_ticket) - else -> "" - } - tvMainLabel.text = when (state.subscribeStatus) { - SubscribeStatus.Free -> getString(R.string.purchase_main_label_normal) - SubscribeStatus.Hold -> getString(R.string.purchase_main_label_free_end) - SubscribeStatus.Subscription -> getString(R.string.purchase_main_label_normal) - SubscribeStatus.SubscriptionExpiration -> getString(R.string.purchase_main_label_subscribe_end) - else -> "" - } - tvSubLabel.text = when (state.subscribeStatus) { - SubscribeStatus.Hold -> getString(R.string.purchase_sub_label_free_end) - SubscribeStatus.SubscriptionExpiration -> getString(R.string.purchase_sub_label_subscribe_end) - else -> "" - } - adapter.submitList(state.ticketsForUi) - } + private fun initListeners() { + binding.tbPurchase.ivBack.setOnClickListener { findNavController().popBackStack() } - private fun handleEvent(event: PurchaseEvent) { - when (event) { - is PurchaseEvent.StartPurchase -> { + binding.btnSubscribe.setOnClickListener { + (viewModel.uiState.value as? PurchaseViewModel.UiState.Loaded)?.let { loaded -> + binding.pbLoading.isVisible = true // TODO JH: 개선 billingViewModel.buyPlans( - productId = event.productId, - tag = event.tag, - upDowngrade = event.upDowngrade, + productId = loaded.subscriptionProductId, + tag = "", + upDowngrade = false, ) } } @@ -85,24 +57,56 @@ class PurchaseFragment : BaseFragment(FragmentPurchaseB private fun initObserve() { viewLifecycleOwner.repeatOnStarted { - billingViewModel.uiEvent.collect { event -> - when (event) { - BillingEvent.PurchaseAcknowledged -> { - PurchaseFragmentDirections - .actionPurchaseFragmentToPurchaseSuccessFragment() - .also { - findNavController().safeNavigate(it) - } + launch { + viewModel.uiState.collect { state -> + when (state) { + PurchaseViewModel.UiState.Failure -> snackbar(R.string.error_something) + is PurchaseViewModel.UiState.Loaded -> { + binding.pbLoading.isVisible = false + updateUi(state) + } + + PurchaseViewModel.UiState.Loading -> binding.pbLoading.isVisible = true + } + } + } + launch { + billingViewModel.uiEvent.collect { event -> + when (event) { + BillingEvent.PurchaseAcknowledged -> { + PurchaseFragmentDirections + .actionPurchaseFragmentToPurchaseSuccessFragment() + .also { + findNavController().safeNavigate(it) + } + } + + is BillingEvent.PurchaseFailed -> { + toast( + getString( + R.string.purchase_error_message, + event.purchaseState, + ), + ) + binding.pbLoading.isVisible = false // TODO JH: 개선 + } } - is BillingEvent.PurchaseFailed -> toast(getString(R.string.purchase_error_message, event.purchaseState)) } } } } - override fun onDestroyView() { - binding.rvSubscribes.removeItemDecoration(spacer) - binding.rvSubscribes.adapter = null - super.onDestroyView() + private fun updateUi(loaded: PurchaseViewModel.UiState.Loaded) { + with(binding) { + with(loaded) { + tvMainLabel.text = description + tvSubLabel.text = subDescription + tvName.text = productName + tvDiscountRate.text = getString(R.string.discount_rate, discountRate) + tvOriginPrice.text = originPrice + tvOriginPrice.strikeThrow() + tvSellPrice.text = sellPrice + } + } } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseState.kt deleted file mode 100644 index 767176d6..00000000 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.nextroom.nextroom.presentation.ui.purchase - -import com.nextroom.nextroom.domain.model.SubscribeStatus -import com.nextroom.nextroom.domain.model.Ticket -import com.nextroom.nextroom.domain.model.UserSubscription - -data class PurchaseState( - val subscribeStatus: SubscribeStatus = SubscribeStatus.None, - val userSubscription: UserSubscription? = UserSubscription(), - private val tickets: List = emptyList(), -) { - val ticketsForUi: List - get() = tickets.map { - it.toPresentation(it.id == userSubscription?.type?.id) - } -} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseViewModel.kt index a06fe5bf..1e5e100d 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/PurchaseViewModel.kt @@ -1,50 +1,67 @@ package com.nextroom.nextroom.presentation.ui.purchase -import androidx.lifecycle.SavedStateHandle -import com.nextroom.nextroom.domain.model.SubscribeItem -import com.nextroom.nextroom.domain.model.SubscribeStatus -import com.nextroom.nextroom.domain.model.Ticket -import com.nextroom.nextroom.domain.model.UserSubscription +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextroom.nextroom.domain.model.onFailure import com.nextroom.nextroom.domain.model.onSuccess import com.nextroom.nextroom.domain.repository.BillingRepository -import com.nextroom.nextroom.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import org.orbitmvi.orbit.Container -import org.orbitmvi.orbit.syntax.simple.intent -import org.orbitmvi.orbit.syntax.simple.postSideEffect -import org.orbitmvi.orbit.syntax.simple.reduce -import org.orbitmvi.orbit.viewmodel.container +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.text.DecimalFormat import javax.inject.Inject @HiltViewModel class PurchaseViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, private val billingRepository: BillingRepository, -) : BaseViewModel() { - override val container: Container = container( - PurchaseState( - subscribeStatus = savedStateHandle["subscribeStatus"] ?: SubscribeStatus.None, - userSubscription = UserSubscription(SubscribeItem(id = "", name = "미니")), - ), - ) +) : ViewModel() { + + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState = _uiState.asStateFlow() init { fetchTickets() } - fun startPurchase(ticket: Ticket) = intent { - postSideEffect( - PurchaseEvent.StartPurchase( - productId = ticket.id, - tag = "", - upDowngrade = (ticket.id == container.stateFlow.value.userSubscription?.type?.id), - ), - ) + private fun fetchTickets() { + viewModelScope.launch { + billingRepository.getTickets().onSuccess { tickets -> + tickets.firstOrNull()?.let { ticket -> + UiState.Loaded( + id = ticket.id, + subscriptionProductId = ticket.subscriptionProductId, + planId = ticket.planId, + productName = ticket.productName, + description = ticket.description, + subDescription = ticket.subDescription, + originPrice = DecimalFormat("#,###원").format(ticket.originPrice), + sellPrice = DecimalFormat("#,###원").format(ticket.sellPrice), + discountRate = ticket.discountRate, + ) + }?.also { + _uiState.emit(it) + } + }.onFailure { + _uiState.emit(UiState.Failure) + } + } } - private fun fetchTickets() = intent { - billingRepository.getTickets().onSuccess { tickets -> - reduce { state.copy(tickets = tickets) } - } + sealed interface UiState { + data object Loading : UiState + data class Loaded( + val id: String, + val subscriptionProductId: String, + val planId: String, + val productName: String, + val description: String, + val subDescription: String, + val originPrice: String, + val sellPrice: String, + val discountRate: Int, + ) : UiState + + data object Failure : UiState } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/TicketAdapter.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/TicketAdapter.kt deleted file mode 100644 index 6148a26b..00000000 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/TicketAdapter.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.nextroom.nextroom.presentation.ui.purchase - -import android.text.Spannable -import android.text.SpannableString -import android.text.style.AbsoluteSizeSpan -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import com.nextroom.nextroom.domain.model.Ticket -import com.nextroom.nextroom.presentation.databinding.ItemTicketBinding -import com.nextroom.nextroom.presentation.extension.strikeThrow -import java.text.DecimalFormat - -class TicketAdapter( - private val onClickTicket: (Ticket) -> Unit, -) : ListAdapter(diffUtil) { - - class TicketViewHolder( - private val binding: ItemTicketBinding, - private val onClickTicket: (Ticket) -> Unit, - ) : ViewHolder(binding.root) { - private lateinit var item: TicketUiModel - - init { - binding.root.setOnClickListener { - if (!item.subscribing) onClickTicket(item.toDomain()) - } - } - - fun bind(ticket: TicketUiModel) = with(binding) { - item = ticket - - tvName.text = ticket.plan - tvDescription.text = ticket.description - tvOriginPrice.isVisible = ticket.originPrice != null - ticket.originPrice?.let { - tvOriginPrice.text = DecimalFormat("#,###원").format(it) - tvOriginPrice.strikeThrow() - } - val intervalUnitText = "/월" - val sellPriceText = DecimalFormat("#,###원$intervalUnitText").format(ticket.sellPrice) - tvSellPrice.text = SpannableString(sellPriceText).apply { - val i = sellPriceText.lastIndexOf(intervalUnitText) - setSpan( - AbsoluteSizeSpan(14, true), - i, - i + intervalUnitText.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } - tvSubscribingBadge.isVisible = ticket.subscribing - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TicketViewHolder { - val binding = ItemTicketBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return TicketViewHolder(binding, ::onClickTicket.get()) - } - - override fun onBindViewHolder(holder: TicketViewHolder, position: Int) { - holder.bind(currentList[position]) - } - - companion object { - private val diffUtil = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: TicketUiModel, newItem: TicketUiModel): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: TicketUiModel, newItem: TicketUiModel): Boolean { - return oldItem == newItem - } - } - } -} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/TicketUiModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/TicketUiModel.kt deleted file mode 100644 index 0e0342f5..00000000 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/purchase/TicketUiModel.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.nextroom.nextroom.presentation.ui.purchase - -import com.nextroom.nextroom.domain.model.Ticket - -data class TicketUiModel( - val id: String, - val plan: String, - val description: String, - val originPrice: Int?, - val sellPrice: Int, - val subscribing: Boolean, -) { - fun toDomain(): Ticket { - return Ticket( - id = id, - plan = plan, - description = description, - originPrice = originPrice, - sellPrice = sellPrice, - ) - } -} - -fun Ticket.toPresentation(subscribing: Boolean): TicketUiModel { - return TicketUiModel( - id = id, - plan = plan, - description = description, - originPrice = originPrice, - sellPrice = sellPrice, - subscribing = subscribing, - ) -} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/util/BillingClientLifecycle.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/util/BillingClientLifecycle.kt index cb45e331..aba8436a 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/util/BillingClientLifecycle.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/util/BillingClientLifecycle.kt @@ -56,9 +56,7 @@ class BillingClientLifecycle private constructor( private var cachedPurchasesList: List? = null // 콘솔에 등록된 상품들 정보 - val largeSubProductWithProductDetails = MutableLiveData() - val mediumSubProductWithProductDetails = MutableLiveData() - val miniSubProductWithProductDetails = MutableLiveData() + val membershipProductWithProductDetails = MutableLiveData() private val _uiEvent = MutableSharedFlow() val uiEvent = _uiEvent.asSharedFlow() @@ -169,9 +167,7 @@ class BillingClientLifecycle private constructor( when (productDetails.productType) { BillingClient.ProductType.SUBS -> { when (productDetails.productId) { - Constants.LARGE_PRODUCT -> largeSubProductWithProductDetails.postValue(productDetails) - Constants.MEDIUM_PRODUCT -> mediumSubProductWithProductDetails.postValue(productDetails) - Constants.MINI_PRODUCT -> miniSubProductWithProductDetails.postValue(productDetails) + Constants.MEMBERSHIP_PRODUCT -> membershipProductWithProductDetails.postValue(productDetails) } } } @@ -252,7 +248,7 @@ class BillingClientLifecycle private constructor( externalScope.launch { val subscriptionPurchaseList = list.filter { purchase -> purchase.products.any { product -> - product in listOf(Constants.LARGE_PRODUCT, Constants.MEDIUM_PRODUCT, Constants.MINI_PRODUCT) + product in listOf(Constants.MEMBERSHIP_PRODUCT) } } @@ -372,11 +368,7 @@ class BillingClientLifecycle private constructor( private const val TAG = "BillingLifecycle" private const val MAX_RETRY_ATTEMPT = 3 - private val LIST_OF_SUBSCRIPTION_PRODUCTS = listOf( - Constants.MINI_PRODUCT, - Constants.MEDIUM_PRODUCT, - Constants.LARGE_PRODUCT, - ) + private val LIST_OF_SUBSCRIPTION_PRODUCTS = listOf(Constants.MEMBERSHIP_PRODUCT) @Volatile private var INSTANCE: BillingClientLifecycle? = null diff --git a/presentation/src/main/res/drawable/ic_navigate_next.xml b/presentation/src/main/res/drawable/ic_navigate_next.xml new file mode 100644 index 00000000..f38dfb7f --- /dev/null +++ b/presentation/src/main/res/drawable/ic_navigate_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/layout/fragment_admin_main.xml b/presentation/src/main/res/layout/fragment_admin_main.xml index b41038be..4034d1df 100644 --- a/presentation/src/main/res/layout/fragment_admin_main.xml +++ b/presentation/src/main/res/layout/fragment_admin_main.xml @@ -29,50 +29,44 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tools:visibility="gone" /> \ No newline at end of file diff --git a/presentation/src/main/res/layout/fragment_mypage.xml b/presentation/src/main/res/layout/fragment_mypage.xml index 393d7b17..f34959e5 100644 --- a/presentation/src/main/res/layout/fragment_mypage.xml +++ b/presentation/src/main/res/layout/fragment_mypage.xml @@ -10,131 +10,85 @@ android:id="@+id/tb_mypage" layout="@layout/common_toolbar" /> - + android:orientation="vertical" + app:layout_constraintGuide_begin="20dp" /> - + - + - + - + - + - - - - - - - - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toTopOf="@id/tv_subscribe" /> + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/fragment_purchase.xml b/presentation/src/main/res/layout/fragment_purchase.xml index c27b56c6..6ff650cc 100644 --- a/presentation/src/main/res/layout/fragment_purchase.xml +++ b/presentation/src/main/res/layout/fragment_purchase.xml @@ -8,98 +8,138 @@ + layout="@layout/common_toolbar" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toTopOf="parent" /> + + - + + + + + - + - + - + - + - + - + - - - + \ No newline at end of file diff --git a/presentation/src/main/res/layout/fragment_subscription_info.xml b/presentation/src/main/res/layout/fragment_subscription_info.xml new file mode 100644 index 00000000..fa931153 --- /dev/null +++ b/presentation/src/main/res/layout/fragment_subscription_info.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/item_ticket.xml b/presentation/src/main/res/layout/item_ticket.xml deleted file mode 100644 index c9d284d6..00000000 --- a/presentation/src/main/res/layout/item_ticket.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/navigation/nav_graph.xml b/presentation/src/main/res/navigation/nav_graph.xml index 12d3a68a..bd7ce696 100644 --- a/presentation/src/main/res/navigation/nav_graph.xml +++ b/presentation/src/main/res/navigation/nav_graph.xml @@ -148,16 +148,19 @@ + + - 이용권 구매 이용권 변경 방탈출 1인 가격보다,\n최저시급보다 저렴하게 + 멤버십 회원을 위한 기능이 업데이트 될 예정입니다 + 새로운 기능이 생기기 전까지 넥스트룸 운영을 위해 후원해주세요 구독이 만료되었어요 무료 체험기간이 끝났어요 다시 구독하시면 저장하신 정보 그대로 사용할 수 있어요! @@ -101,9 +103,17 @@ 지금 바로 넥스트룸을 사용해보세요 일시적인 오류이거나 서비스 장애입니다. 다시 시도하여도 해당 메시지가 계속 보일 경우 상태 코드와 함께 문의 바랍니다.\n상태 코드: %d 구독 기간 + 구독 상태 + 구독 정보 마이페이지 이동 My %d원 %d원/월 구독중 + 구독하지 않음 + 구독하기 + 구독 + /월 + %d%% + %s ~ %s \ No newline at end of file