diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/BaseViewModelFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/BaseViewModelFragment.kt index 28ddb2b4..1567bf7c 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/BaseViewModelFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/BaseViewModelFragment.kt @@ -1,5 +1,6 @@ package com.nextroom.nextroom.presentation.base +import androidx.fragment.app.setFragmentResultListener import androidx.navigation.fragment.findNavController import androidx.viewbinding.ViewBinding import com.nextroom.nextroom.presentation.NavGraphDirections @@ -13,22 +14,38 @@ abstract class BaseViewModelFragment(pr NewBaseFragment(inflate) { abstract val viewModel: VM + private var pendingErrorAction: NewBaseViewModel.ErrorAction = NewBaseViewModel.ErrorAction.STAY + override fun initObserve() { super.initObserve() + setFragmentResultListener(ERROR_DIALOG_KEY) { _, _ -> + val action = pendingErrorAction + pendingErrorAction = NewBaseViewModel.ErrorAction.STAY + if (action == NewBaseViewModel.ErrorAction.POP_BACK_STACK) { + findNavController().popBackStack() + } + } + viewLifecycleOwner.repeatOnStarted { launch { - viewModel.errorFlow.collect { + viewModel.errorFlow.collect { event -> + pendingErrorAction = event.action NavGraphDirections.moveToNrOneButtonDialog( NROneButtonDialog.NROneButtonArgument( title = getString(R.string.dialog_noti), message = getString(R.string.error_something), btnText = getString(R.string.text_confirm), - errorText = it.message, + errorText = event.throwable.message, + dialogKey = ERROR_DIALOG_KEY, ) ).also { findNavController().safeNavigate(it) } } } } } -} \ No newline at end of file + + companion object { + private const val ERROR_DIALOG_KEY = "base_view_model_error_dialog" + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt index 5b41dca8..4950ccea 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/ComposeBaseViewModelFragment.kt @@ -1,5 +1,6 @@ package com.nextroom.nextroom.presentation.base +import androidx.fragment.app.setFragmentResultListener import androidx.navigation.fragment.findNavController import com.nextroom.nextroom.presentation.NavGraphDirections import com.nextroom.nextroom.presentation.R @@ -11,22 +12,38 @@ import kotlinx.coroutines.launch abstract class ComposeBaseViewModelFragment : ComposeBaseFragment() { abstract val viewModel: VM + private var pendingErrorAction: NewBaseViewModel.ErrorAction = NewBaseViewModel.ErrorAction.STAY + override fun initObserve() { super.initObserve() + setFragmentResultListener(ERROR_DIALOG_KEY) { _, _ -> + val action = pendingErrorAction + pendingErrorAction = NewBaseViewModel.ErrorAction.STAY + if (action == NewBaseViewModel.ErrorAction.POP_BACK_STACK) { + findNavController().popBackStack() + } + } + viewLifecycleOwner.repeatOnStarted { launch { - viewModel.errorFlow.collect { + viewModel.errorFlow.collect { event -> + pendingErrorAction = event.action NavGraphDirections.moveToNrOneButtonDialog( NROneButtonDialog.NROneButtonArgument( title = getString(R.string.dialog_noti), message = getString(R.string.error_something), btnText = getString(R.string.text_confirm), - errorText = it.message, + errorText = event.throwable.message, + dialogKey = ERROR_DIALOG_KEY, ) ).also { findNavController().safeNavigate(it) } } } } } -} \ No newline at end of file + + companion object { + private const val ERROR_DIALOG_KEY = "compose_base_error_dialog" + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/NewBaseViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/NewBaseViewModel.kt index 4edc7604..7cf69570 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/base/NewBaseViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/base/NewBaseViewModel.kt @@ -5,8 +5,8 @@ import androidx.lifecycle.viewModelScope import com.nextroom.nextroom.presentation.BuildConfig import com.nextroom.nextroom.presentation.util.Logger import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.plus import kotlin.coroutines.cancellation.CancellationException @@ -20,10 +20,10 @@ abstract class NewBaseViewModel : ViewModel() { protected val baseViewModelScope = viewModelScope + exceptionHandler - private val _errorFlow = MutableSharedFlow(extraBufferCapacity = 1) - val errorFlow = _errorFlow.asSharedFlow() + private val _errorChannel = Channel(Channel.BUFFERED) + val errorFlow = _errorChannel.receiveAsFlow() - fun handleError(throwable: Throwable) { + fun handleError(throwable: Throwable, action: ErrorAction = ErrorAction.STAY) { when (throwable) { is CancellationException -> Unit else -> { @@ -31,8 +31,12 @@ abstract class NewBaseViewModel : ViewModel() { Logger.e("${this::class.simpleName} generated\n${throwable.message}") } - _errorFlow.tryEmit(throwable) + _errorChannel.trySend(ErrorEvent(throwable, action)) } } } -} \ No newline at end of file + + enum class ErrorAction { STAY, POP_BACK_STACK } + + data class ErrorEvent(val throwable: Throwable, val action: ErrorAction) +} 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 8443e37d..8acad4ee 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,17 +3,22 @@ package com.nextroom.nextroom.presentation.ui.mypage import android.content.Intent import android.net.Uri import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.core.view.isVisible +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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.NavGraphDirections import com.nextroom.nextroom.presentation.R -import com.nextroom.nextroom.presentation.base.BaseFragment +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog -import com.nextroom.nextroom.presentation.databinding.FragmentMypageBinding +import com.nextroom.nextroom.presentation.common.compose.NRLoading import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate import com.nextroom.nextroom.presentation.extension.snackbar @@ -22,70 +27,45 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class MypageFragment : BaseFragment(FragmentMypageBinding::inflate) { - - private val viewModel: MypageViewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - initViews() - initListeners() - initObserve() - setFragmentResultListeners() - } - - private fun initViews() = with(binding) { - tbMypage.apply { - tvButton.isVisible = false - tvTitle.text = getString(R.string.mypage_title) - } - } +class MypageFragment : ComposeBaseViewModelFragment() { + + override val screenName: String = "mypage" + + override val viewModel: MypageViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val uiState by viewModel.uiState.collectAsState() + when (val state = uiState) { + is MypageViewModel.UiState.Loaded -> { + MypageScreen( + state = state, + onBackClick = { findNavController().popBackStack() }, + onSubscribeClick = ::onSubscribeClick, + onChangeAppPasswordClick = ::moveToSetPassword, + onCustomerServiceClick = ::openCustomerService, + onLogoutClick = viewModel::logout, + onResignClick = ::showConfirmResignDialog, + ) + } - 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.SUBSCRIPTION_EXPIRATION, - SubscribeStatus.Default -> goToPurchase() - - SubscribeStatus.Subscribed -> goToSubscriptionInfo() + MypageViewModel.UiState.Failure, + MypageViewModel.UiState.Loading -> NRLoading(true) } } } - clChangeAppPassword.setOnClickListener { - moveToSetPassword() - } - clCustomerService.setOnClickListener { - try { - getString(R.string.link_official_instagram).let { url -> - Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(url) } - }.also { startActivity(it) } - } catch (e: Exception) { - toast(getString(R.string.error_something)) - } - } } - private fun initObserve() { + override fun initObserve() { + super.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.tvAppVersion.text = state.appVersion - } - - MypageViewModel.UiState.Loading -> binding.pbLoading.isVisible = true - } - } - } launch { viewModel.uiEvent.collect { event -> when (event) { @@ -97,12 +77,33 @@ class MypageFragment : BaseFragment(FragmentMypageBinding } } - private fun setFragmentResultListeners() { + override fun setFragmentResultListeners() { setFragmentResultListener(REQUEST_KEY_RESIGN) { _, _ -> viewModel.resign() } } + private fun onSubscribeClick() { + val loaded = viewModel.uiState.value as? MypageViewModel.UiState.Loaded ?: return + when (loaded.status) { + SubscribeStatus.SUBSCRIPTION_EXPIRATION, + SubscribeStatus.Default -> goToPurchase() + + SubscribeStatus.Subscribed -> goToSubscriptionInfo() + } + } + + private fun openCustomerService() { + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.link_official_instagram)) + } + startActivity(intent) + } catch (e: Exception) { + toast(getString(R.string.error_something)) + } + } + private fun goToPurchase() { val action = NavGraphDirections.moveToPurchaseFragment() findNavController().safeNavigate(action) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageScreen.kt new file mode 100644 index 00000000..f789973e --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/mypage/MypageScreen.kt @@ -0,0 +1,213 @@ +package com.nextroom.nextroom.presentation.ui.mypage + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRToolbar +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.extension.throttleClick + +@Composable +fun MypageScreen( + state: MypageViewModel.UiState.Loaded, + onBackClick: () -> Unit, + onSubscribeClick: () -> Unit, + onChangeAppPasswordClick: () -> Unit, + onCustomerServiceClick: () -> Unit, + onLogoutClick: () -> Unit, + onResignClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01), + ) { + Column(modifier = Modifier.fillMaxSize()) { + NRToolbar( + title = stringResource(R.string.mypage_title), + onBackClick = onBackClick, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringResource(R.string.admin_main_shop_name_label), + style = NRTypo.Pretendard.size14SemiBold, + color = NRColor.White, + modifier = Modifier.padding(start = 20.dp, top = 12.dp), + ) + + Text( + text = state.shopName, + style = NRTypo.Pretendard.size24, + color = NRColor.White, + modifier = Modifier.padding(start = 20.dp, top = 8.dp), + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .height(1.dp) + .background(NRColor.Gray02), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + MypageMenuRow( + title = stringResource(R.string.subscribe), + onClick = onSubscribeClick, + ) + + MypageMenuRow( + title = stringResource(R.string.text_change_app_password), + onClick = onChangeAppPasswordClick, + ) + + MypageMenuRow( + title = stringResource(R.string.text_customer_service), + onClick = onCustomerServiceClick, + ) + + MypageAppVersionRow(appVersion = state.appVersion) + + Spacer(modifier = Modifier.weight(1f)) + + MypageBottomActions( + onLogoutClick = onLogoutClick, + onResignClick = onResignClick, + ) + } + } +} + +@Composable +private fun MypageMenuRow( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .throttleClick { onClick() } + .padding(horizontal = 20.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = NRTypo.Pretendard.size16Bold, + color = NRColor.White, + modifier = Modifier.weight(1f), + ) + Image( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun MypageAppVersionRow( + appVersion: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.text_app_version), + style = NRTypo.Pretendard.size16Bold, + color = NRColor.White, + modifier = Modifier.weight(1f), + ) + Text( + text = appVersion, + style = NRTypo.Pretendard.size16, + color = NRColor.White50, + ) + } +} + +@Composable +private fun MypageBottomActions( + onLogoutClick: () -> Unit, + onResignClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.logout_button), + style = NRTypo.Pretendard.size14, + color = NRColor.White50, + modifier = Modifier + .throttleClick { onLogoutClick() } + .padding(16.dp), + ) + Box( + modifier = Modifier + .width(1.dp) + .height(10.dp) + .background(NRColor.White50), + ) + Text( + text = stringResource(R.string.text_user_resign), + style = NRTypo.Pretendard.size14, + color = NRColor.White50, + modifier = Modifier + .throttleClick { onResignClick() } + .padding(16.dp), + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun MypageScreenPreview() { + MypageScreen( + state = MypageViewModel.UiState.Loaded( + shopName = "비트포비아 강남 2호점", + status = SubscribeStatus.Subscribed, + appVersion = "1.4.7", + ), + onBackClick = {}, + onSubscribeClick = {}, + onChangeAppPasswordClick = {}, + onCustomerServiceClick = {}, + onLogoutClick = {}, + onResignClick = {}, + ) +} 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 43517fac..af0d102d 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,12 +1,11 @@ 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.onSuccess import com.nextroom.nextroom.domain.repository.AdminRepository +import com.nextroom.nextroom.presentation.base.NewBaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +21,7 @@ import javax.inject.Named class MypageViewModel @Inject constructor( private val adminRepository: AdminRepository, @Named("app_version") private val appVersion: String, -) : ViewModel() { +) : NewBaseViewModel() { private val _myInfo = MutableStateFlow(UiState.Loading) private val _isResignLoading = MutableStateFlow(false) @@ -36,7 +35,7 @@ class MypageViewModel @Inject constructor( } else { myInfo } - }.stateIn(viewModelScope, SharingStarted.Lazily, UiState.Loading) + }.stateIn(baseViewModelScope, SharingStarted.Lazily, UiState.Loading) private val _uiEvent = MutableSharedFlow() val uiEvent = _uiEvent.asSharedFlow() @@ -46,14 +45,16 @@ class MypageViewModel @Inject constructor( } fun logout() { - viewModelScope.launch { + baseViewModelScope.launch { adminRepository.logout() } } private fun fetchMyInfo() { - viewModelScope.launch { - adminRepository.getUserSubscribe().onSuccess { mypage -> + baseViewModelScope.launch { + runCatching { + adminRepository.getUserSubscribe().getOrThrow + }.onSuccess { mypage -> UiState.Loaded( shopName = mypage.name, status = mypage.status, @@ -62,13 +63,13 @@ class MypageViewModel @Inject constructor( _myInfo.emit(it) } }.onFailure { - _myInfo.emit(UiState.Failure) + handleError(it) } } } fun resign() { - viewModelScope.launch { + baseViewModelScope.launch { _isResignLoading.emit(true) adminRepository.resign().onSuccess { _uiEvent.emit(UiEvent.ResignSuccess) diff --git a/presentation/src/main/res/layout/fragment_mypage.xml b/presentation/src/main/res/layout/fragment_mypage.xml deleted file mode 100644 index 10270660..00000000 --- a/presentation/src/main/res/layout/fragment_mypage.xml +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/navigation/mypage_navigation.xml b/presentation/src/main/res/navigation/mypage_navigation.xml index 387063f0..bf809657 100644 --- a/presentation/src/main/res/navigation/mypage_navigation.xml +++ b/presentation/src/main/res/navigation/mypage_navigation.xml @@ -7,8 +7,7 @@ + android:name="com.nextroom.nextroom.presentation.ui.mypage.MypageFragment">