diff --git a/app/src/main/java/com/runnect/runnect/presentation/base/BaseViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/base/BaseViewModel.kt index 55119415..bbdb57f0 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/base/BaseViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/base/BaseViewModel.kt @@ -2,11 +2,15 @@ package com.runnect.runnect.presentation.base import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.runnect.runnect.domain.common.toLog import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import retrofit2.HttpException import timber.log.Timber @@ -15,6 +19,23 @@ import java.net.UnknownHostException abstract class BaseViewModel : ViewModel() { + sealed interface EventState { + object Empty : EventState + data class ShowToast(val message: String) : EventState + data class ShowSnackBar(val message: String) : EventState + data class NetworkError(val message: String) : EventState + data class UnknownError(val message: String) : EventState + } + + private val _eventState: MutableSharedFlow = MutableSharedFlow() + val eventState: SharedFlow = _eventState.asSharedFlow() + + protected fun sendEvent(event: EventState) { + viewModelScope.launch { + _eventState.emit(event) + } + } + open val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Timber.tag(throwable::class.java.simpleName).e(throwable) @@ -22,9 +43,15 @@ abstract class BaseViewModel : ViewModel() { is SocketException, is HttpException, is UnknownHostException -> { + sendEvent( + EventState.NetworkError(throwable.toLog()) + ) Timber.e(throwable) } else -> { + sendEvent( + EventState.UnknownError(throwable.toLog()) + ) Timber.e(throwable) } } diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt index e0aebe72..9d8447cd 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt @@ -1,7 +1,6 @@ package com.runnect.runnect.presentation.mypage import android.app.Activity.RESULT_OK -import android.content.ContentValues import android.content.Intent import android.os.Bundle import android.view.View @@ -18,64 +17,78 @@ import com.runnect.runnect.R import com.runnect.runnect.binding.BindingFragment import com.runnect.runnect.databinding.FragmentMyPageBinding import com.runnect.runnect.presentation.MainActivity +import com.runnect.runnect.presentation.base.BaseViewModel import com.runnect.runnect.presentation.login.LoginActivity +import com.runnect.runnect.presentation.mypage.dto.UserDto import com.runnect.runnect.presentation.mypage.editname.MyPageEditNameActivity import com.runnect.runnect.presentation.mypage.history.MyHistoryActivity import com.runnect.runnect.presentation.mypage.reward.MyRewardActivity import com.runnect.runnect.presentation.mypage.setting.MySettingFragment import com.runnect.runnect.presentation.mypage.upload.MyUploadActivity -import com.runnect.runnect.presentation.state.UiState import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_GOAL_REWARD import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_RUNNING_RECORD import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_UPLOADED_COURSE +import com.runnect.runnect.util.extension.applyScreenEnterAnimation import com.runnect.runnect.util.extension.getStampResId +import com.runnect.runnect.util.extension.repeatOnStarted +import com.runnect.runnect.util.extension.showSnackbar import dagger.hilt.android.AndroidEntryPoint -import timber.log.Timber @AndroidEntryPoint class MyPageFragment : BindingFragment(R.layout.fragment_my_page) { + private val viewModel: MyPageViewModel by activityViewModels() + + private val userData: UserDto + get() = viewModel.userData.value + + private val stampResId: Int + get() = activity?.run { + getStampResId( + stampId = userData.stampId, + resNameParam = RES_NAME, + resType = RES_STAMP_TYPE, + packageName = packageName + ) + } ?: 0 + private lateinit var resultEditNameLauncher: ActivityResultLauncher - var isVisitorMode: Boolean = MainActivity.isVisitorMode + private var isVisitorMode: Boolean = MainActivity.isVisitorMode + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + if (isVisitorMode) { activateVisitorMode() } else { deactivateVisitorMode() } - } private fun activateVisitorMode() { with(binding) { - ivVisitorMode.isVisible = true - tvVisitorMode.isVisible = true - btnVisitorMode.isVisible = true - constraintInside.isVisible = false + setVisitorMode(true) btnVisitorMode.setOnClickListener { - val intent = Intent(requireContext(), LoginActivity::class.java) - startActivity(intent) - requireActivity().finish() + activity?.let { + Intent(it, LoginActivity::class.java).let(::startActivity) + it.finish() + } } } } private fun deactivateVisitorMode() { + setVisitorMode(false) + addListener() + addObserver() + setResultEditNameLauncher() + + viewModel.getUserInfo() with(binding) { - ivVisitorMode.isVisible = false - tvVisitorMode.isVisible = false - btnVisitorMode.isVisible = false - constraintInside.isVisible = true - - binding.vm = viewModel - binding.lifecycleOwner = this@MyPageFragment.viewLifecycleOwner - viewModel.getUserInfo() - addListener() - addObserver() - setResultEditNameLauncher() + vm = viewModel + lifecycleOwner = this@MyPageFragment.viewLifecycleOwner } } @@ -83,59 +96,51 @@ class MyPageFragment : BindingFragment(R.layout.fragment_ resultEditNameLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { - val name = - result.data?.getStringExtra(EXTRA_NICK_NAME) ?: viewModel.nickName.value - viewModel.setNickName(name!!) + val name = result.data?.getStringExtra(EXTRA_NICK_NAME) ?: "" + viewModel.updateUser( + userData.copy(nickName = name) + ) } } } private fun addListener() { binding.ivMyPageEditFrame.setOnClickListener { - val intent = Intent(requireContext(), MyPageEditNameActivity::class.java) - intent.putExtra(EXTRA_NICK_NAME, "${viewModel.nickName.value}") - val stampResId = requireContext().getStampResId( - stampId = viewModel.stampId.value, - resNameParam = RES_NAME, - resType = RES_STAMP_TYPE, - packageName = requireContext().packageName - ) - intent.putExtra(EXTRA_PROFILE, stampResId) - resultEditNameLauncher.launch(intent) + Intent(requireContext(), MyPageEditNameActivity::class.java).apply { + putExtra(EXTRA_NICK_NAME, userData.nickName) + putExtra(EXTRA_PROFILE, stampResId) + }.let(resultEditNameLauncher::launch) } binding.viewMyPageMainRewardFrame.setOnClickListener { Analytics.logClickedItemEvent(EVENT_CLICK_GOAL_REWARD) - startActivity(Intent(requireContext(), MyRewardActivity::class.java)) - requireActivity().overridePendingTransition( - R.anim.slide_in_right, R.anim.slide_out_left - ) + startActivityWithAnimation(MyRewardActivity::class.java) } + binding.viewMyPageMainHistoryFrame.setOnClickListener { Analytics.logClickedItemEvent(EVENT_CLICK_RUNNING_RECORD) - startActivity(Intent(requireContext(), MyHistoryActivity::class.java)) - requireActivity().overridePendingTransition( - R.anim.slide_in_right, R.anim.slide_out_left - ) + startActivityWithAnimation(MyHistoryActivity::class.java) } binding.viewMyPageMainUploadFrame.setOnClickListener { Analytics.logClickedItemEvent(EVENT_CLICK_UPLOADED_COURSE) - startActivity(Intent(requireContext(), MyUploadActivity::class.java)) - requireActivity().overridePendingTransition( - R.anim.slide_in_right, R.anim.slide_out_left - ) + startActivityWithAnimation(MyUploadActivity::class.java) } + binding.viewMyPageMainSettingFrame.setOnClickListener { moveToSettingFragment() } + binding.viewMyPageMainKakaoChannelInquiryFrame.setOnClickListener { inquiryKakao() } } private fun moveToSettingFragment() { - val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.email.value) } + val bundle = Bundle().apply { + putString(ACCOUNT_INFO_TAG, userData.email) + } + requireActivity().supportFragmentManager.commit { this.setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left) replace(R.id.fl_main, args = bundle) @@ -143,37 +148,65 @@ class MyPageFragment : BindingFragment(R.layout.fragment_ } private fun addObserver() { - viewModel.nickName.observe(viewLifecycleOwner) { nickName -> - binding.tvMyPageUserName.text = nickName.toString() + repeatOnStarted( + { + viewModel.userState.collect { + when (it) { + is MyPageViewModel.UserState.Loading -> handleLoading(true) + is MyPageViewModel.UserState.UserUpdated -> handleSuccess() + is MyPageViewModel.UserState.Failure -> handleFailure() + } + } + }, + { + viewModel.eventState.collect { + when (it) { + is BaseViewModel.EventState.ShowSnackBar -> { + context?.showSnackbar(binding.root, it.message) + } + is BaseViewModel.EventState.NetworkError -> { + context?.showSnackbar(binding.root, it.message) + } + else -> Unit + } + } + } + ) + } + + private fun handleLoading(isLoading: Boolean) { + with(binding) { + indeterminateBar.isVisible = isLoading + ivMyPageEditFrame.isClickable = !isLoading + viewMyPageMainSettingFrame.isClickable = !isLoading } + } - viewModel.userInfoState.observe(viewLifecycleOwner) { - when (it) { - UiState.Empty -> binding.indeterminateBar.isVisible = false - UiState.Loading -> { - binding.indeterminateBar.isVisible = true - binding.ivMyPageEditFrame.isClickable = false - binding.viewMyPageMainSettingFrame.isClickable = false - } + private fun handleSuccess() { + handleLoading(false) + viewModel.updateUser( + userData.copy(profileImgResId = stampResId) + ) + binding.tvMyPageUserName.text = userData.nickName + } - UiState.Success -> { - binding.indeterminateBar.isVisible = false - val stampResId = requireContext().getStampResId( - viewModel.stampId.value, - RES_NAME, - RES_STAMP_TYPE, - requireContext().packageName - ) - viewModel.setProfileImg(stampResId) - binding.ivMyPageEditFrame.isClickable = true - binding.viewMyPageMainSettingFrame.isClickable = true - } + private fun handleFailure() { + binding.indeterminateBar.isVisible = false + } - UiState.Failure -> { - binding.indeterminateBar.isVisible = false - Timber.tag(ContentValues.TAG).d("Failure : ${viewModel.errorMessage.value}") - } - } + private fun setVisitorMode(isVisitor: Boolean) { + with(binding) { + ivVisitorMode.isVisible = isVisitor + tvVisitorMode.isVisible = isVisitor + btnVisitorMode.isVisible = isVisitor + constraintInside.isVisible = !isVisitor + } + } + + private fun startActivityWithAnimation(activityClass: Class<*>) { + activity?.let { + Intent(it, activityClass).let(::startActivity) + it.applyScreenEnterAnimation() } } diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt index 59b47976..b37e462b 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt @@ -1,14 +1,16 @@ package com.runnect.runnect.presentation.mypage -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.runnect.runnect.R import com.runnect.runnect.domain.common.toLog import com.runnect.runnect.domain.repository.UserRepository import com.runnect.runnect.presentation.base.BaseViewModel -import com.runnect.runnect.presentation.state.UiState +import com.runnect.runnect.presentation.mypage.dto.UserDto import com.runnect.runnect.util.extension.collectResult import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import javax.inject.Inject @@ -17,50 +19,39 @@ class MyPageViewModel @Inject constructor( private val userRepository: UserRepository ) : BaseViewModel() { - val nickName: MutableLiveData = MutableLiveData() - val stampId: MutableLiveData = MutableLiveData(STAMP_LOCK) - val profileImgResId: MutableLiveData = MutableLiveData(R.drawable.user_profile_basic) - val level: MutableLiveData = MutableLiveData() - val levelPercent: MutableLiveData = MutableLiveData() - val email: MutableLiveData = MutableLiveData() + sealed interface UserState { + object Loading : UserState + object UserUpdated : UserState + data class Failure(val message: String) : UserState + } - private val _userInfoState = MutableLiveData(UiState.Loading) - val userInfoState: LiveData - get() = _userInfoState + private val _userState: MutableStateFlow = MutableStateFlow(UserState.Loading) + val userState: StateFlow = _userState.asStateFlow() + + private val _userData = MutableStateFlow(UserDto()) + val userData: StateFlow = _userData.asStateFlow() - val errorMessage = MutableLiveData() - fun setNickName(nickName: String) { - this.nickName.value = nickName - } - fun setProfileImg(profileImgResId: Int) { - this.profileImgResId.value = profileImgResId + fun updateUser(user: UserDto) { + _userState.value = UserState.UserUpdated.also { + _userData.value = user + } } fun getUserInfo() = launchWithHandler { userRepository.getUserInfo() + .flowOn(Dispatchers.IO) .onStart { - _userInfoState.value = UiState.Loading - }.collectResult( + _userState.tryEmit(UserState.Loading) + }.collectResult ( onSuccess = { user -> - user.let { - level.value = it.level.toString() - nickName.value = it.nickname - stampId.value = it.latestStamp - levelPercent.value = it.levelPercent - email.value = it.email - } - - _userInfoState.value = UiState.Success + val userDto = UserDto.toDto(user) + updateUser(userDto) }, onFailure = { - errorMessage.value = it.toLog() - _userInfoState.value = UiState.Failure + _userState.value = UserState.Failure(it.toLog()) + sendEvent(EventState.ShowSnackBar(it.toLog())) } ) } - - companion object { - const val STAMP_LOCK = "lock" - } } \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/presentation/mypage/dto/UserDto.kt b/app/src/main/java/com/runnect/runnect/presentation/mypage/dto/UserDto.kt new file mode 100644 index 00000000..425e6942 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/mypage/dto/UserDto.kt @@ -0,0 +1,25 @@ +package com.runnect.runnect.presentation.mypage.dto + +import com.runnect.runnect.R +import com.runnect.runnect.domain.entity.User + +data class UserDto( + val nickName: String = "", + val email: String = "", + val level: String = "", + val levelPercent: Int = 0, + val stampId: String = STAMP_LOCK, + val profileImgResId: Int = R.drawable.user_profile_basic, +) { + companion object { + const val STAMP_LOCK = "lock" + + fun toDto(user: User) = UserDto( + nickName = user.nickname, + email = user.email, + stampId = user.latestStamp, + level = user.level.toString(), + levelPercent = user.levelPercent, + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_my_page.xml b/app/src/main/res/layout/fragment_my_page.xml index 471724cf..92648ffe 100644 --- a/app/src/main/res/layout/fragment_my_page.xml +++ b/app/src/main/res/layout/fragment_my_page.xml @@ -120,7 +120,7 @@ android:layout_width="63dp" android:layout_height="0dp" android:layout_marginStart="23dp" - setLocalImageByResourceId="@{vm.profileImgResId}" + setLocalImageByResourceId="@{vm.userData.profileImgResId}" app:layout_constraintBottom_toBottomOf="@id/view_my_page_profile_frame" app:layout_constraintDimensionRatio="1:1" app:layout_constraintStart_toStartOf="@id/view_my_page_profile_frame" @@ -132,7 +132,7 @@ android:layout_height="wrap_content" android:layout_marginStart="10dp" android:fontFamily="@font/pretendard_bold" - android:text="@{vm.nickName}" + android:text="@{vm.userData.nickName}" android:textColor="@color/M1" android:textSize="17sp" app:layout_constraintBottom_toBottomOf="@id/iv_my_page_profile" @@ -207,7 +207,7 @@ android:height="22dp" android:fontFamily="@font/pretendard_bold" android:gravity="center" - android:text="@{vm.level}" + android:text="@{vm.userData.level}" android:textColor="@color/G1" android:textSize="15sp" app:layout_constraintBottom_toBottomOf="@id/tv_my_page_user_lv_indicator" @@ -223,7 +223,7 @@ android:layout_marginHorizontal="22dp" android:layout_marginTop="6dp" android:max="100" - android:progress="@{vm.levelPercent}" + android:progress="@{vm.userData.levelPercent}" android:progressDrawable="@drawable/progressbar_custom" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -235,7 +235,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="1dp" android:fontFamily="@font/pretendard_semibold" - android:text="@{vm.levelPercent.toString()}" + android:text="@{vm.userData.levelPercent + ``}" android:textColor="@color/G1" android:textSize="13sp" app:layout_constraintBottom_toBottomOf="@id/tv_my_page_progress_max"