Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.into.websoso.BuildConfig
import com.into.websoso.core.common.ui.custom.WebsosoCustomSnackBar
import com.into.websoso.core.common.ui.custom.WebsosoCustomToast
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import java.io.Serializable

fun Float.toFloatPxFromDp(): Float = this * Resources.getSystem().displayMetrics.density
Expand Down Expand Up @@ -137,3 +143,15 @@ fun Modifier.clickableWithoutRipple(
onClick = onClick,
)
}

fun <T> Flow<T>.collectWithLifecycle(
lifecycleOwner: LifecycleOwner,
state: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend (T) -> Unit,
) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(state) {
collect { collector(it) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() {
return TaskStackBuilder.create(this).run {
addNextIntent(mainIntent)
addNextIntentWithParentStack(detailIntent)
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
getPendingIntent(
notificationId.toInt(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.into.websoso.ui.main.home.adpater.PopularFeedsAdapter
import com.into.websoso.ui.main.home.adpater.PopularNovelsAdapter
import com.into.websoso.ui.main.home.adpater.RecommendedNovelsByUserTasteAdapter
import com.into.websoso.ui.main.home.adpater.UserInterestFeedAdapter
import com.into.websoso.ui.main.home.dialog.TermsAgreementDialogFragment
import com.into.websoso.ui.normalExplore.NormalExploreActivity
import com.into.websoso.ui.notification.NotificationActivity
import com.into.websoso.ui.novelDetail.NovelDetailActivity
Expand Down Expand Up @@ -90,6 +91,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {
onPostInterestNovelClick()
onSettingPreferenceGenreClick()
onNotificationButtonClick()
showTermsAgreementDialog()
tracker.trackEvent("home")
}

Expand Down Expand Up @@ -293,6 +295,11 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {
}
}

private fun showTermsAgreementDialog() {
val dialog = TermsAgreementDialogFragment.newInstance()
dialog.show(parentFragmentManager, TermsAgreementDialogFragment.TERMS_AGREEMENT_TAG)
}

companion object {
private const val TODAY_POPULAR_NOVEL_MARGIN = 15
private const val USER_INTEREST_MARGIN = 14
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.into.websoso.ui.main.home.dialog

import android.os.Bundle
import android.view.View
import com.into.websoso.R
import com.into.websoso.core.common.ui.base.BaseDialogFragment
import com.into.websoso.databinding.DialogTermsAgreementPopupMenuBinding
import com.into.websoso.ui.termsAgreement.TermsAgreementDialogBottomSheet

class TermsAgreementDialogFragment :
BaseDialogFragment<DialogTermsAgreementPopupMenuBinding>(R.layout.dialog_terms_agreement_popup_menu) {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isCancelable = false
onTermsAgreementPopupMenuUpdateClick()
}

private fun onTermsAgreementPopupMenuUpdateClick() {
binding.tvTermsAgreementPopupMenuUpdate.setOnClickListener {
showTermsAgreementBottomSheet()
dismiss()
}
}

private fun showTermsAgreementBottomSheet() {
TermsAgreementDialogBottomSheet.newInstance(isFromHome = true)
.show(parentFragmentManager, "TermsAgreementDialogBottomSheet")
}

companion object {
const val TERMS_AGREEMENT_TAG = "TermsAgreementDialog"

fun newInstance(): TermsAgreementDialogFragment {
return TermsAgreementDialogFragment()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.into.websoso.core.common.util.SingleEventHandler
import com.into.websoso.databinding.ActivityOnboardingBinding
import com.into.websoso.ui.onboarding.model.OnboardingPage
import com.into.websoso.ui.onboarding.welcome.WelcomeActivity
import com.into.websoso.ui.termsAgreement.TermsAgreementDialogBottomSheet
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
Expand All @@ -26,6 +27,7 @@ class OnboardingActivity : BaseActivity<ActivityOnboardingBinding>(R.layout.acti
setupViewPager()
setupObserver()
onSkipGeneralButtonClick()
showTermsAgreementDialog()
}

private fun bindViewModel() {
Expand Down Expand Up @@ -80,6 +82,11 @@ class OnboardingActivity : BaseActivity<ActivityOnboardingBinding>(R.layout.acti
}
}

private fun showTermsAgreementDialog() {
TermsAgreementDialogBottomSheet.newInstance()
.show(supportFragmentManager, "TermsAgreementDialog")
}

companion object {
private const val ANIMATION_PROPERTY_NAME = "progress"
private const val ANIMATION_DURATION_TIME = 200L
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package com.into.websoso.ui.termsAgreement

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.into.websoso.R
import com.into.websoso.R.drawable.bg_novel_rating_date_primary_100_radius_12dp
import com.into.websoso.R.drawable.bg_profile_edit_gray_70_radius_12dp
import com.into.websoso.R.drawable.ic_terms_agreement_selected
import com.into.websoso.R.drawable.ic_terms_agreement_unselected
import com.into.websoso.R.string.string_terms_agreement_complete
import com.into.websoso.R.string.string_terms_agreement_next
import com.into.websoso.core.common.ui.base.BaseBottomSheetDialog
import com.into.websoso.core.common.util.collectWithLifecycle
import com.into.websoso.databinding.DialogTermsAgreementBinding
import com.into.websoso.ui.termsAgreement.model.AgreementType
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class TermsAgreementDialogBottomSheet :
BaseBottomSheetDialog<DialogTermsAgreementBinding>(R.layout.dialog_terms_agreement) {

private val termsAgreementViewModel: TermsAgreementViewModel by viewModels()

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)

setupBottomSheetDialog()
onRequiredTermsAgreementClick()
onTermsAgreementToggleClick()
onTermsAgreementCompleteButtonClick()
setupViewModel()
updateCompleteButtonText()
}

private fun setupBottomSheetDialog() {
(dialog as BottomSheetDialog).apply {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.isDraggable = false
behavior.isHideable = false
setCancelable(false)
}
}

private fun onRequiredTermsAgreementClick() {
binding.tvTermsAgreementService.setOnClickListener {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.terms_agreement_service)),
),
)
}

binding.tvTermsAgreementPrivacy.setOnClickListener {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.terms_agreement_privacy)),
),
)
}
}

private fun onTermsAgreementToggleClick() {
binding.ivTermsAgreementAll.setOnClickListener { termsAgreementViewModel.updateTermsAgreementsAll() }

binding.ivTermsAgreementService.setOnClickListener {
termsAgreementViewModel.updateTermsAgreements(
AgreementType.SERVICE,
)
}

binding.ivTermsAgreementPrivacy.setOnClickListener {
termsAgreementViewModel.updateTermsAgreements(
AgreementType.PRIVACY,
)
}

binding.ivTermsAgreementMarketing.setOnClickListener {
termsAgreementViewModel.updateTermsAgreements(
AgreementType.MARKETING,
)
}

}

private fun onTermsAgreementCompleteButtonClick() {
binding.btnTermsAgreementComplete.setOnClickListener { dismissIfAgreementsCompleted() }
}

private fun dismissIfAgreementsCompleted() {
if (termsAgreementViewModel.isRequiredAgreementsChecked.value) {
dismiss()
}
}
Comment on lines +96 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: 뎁스를 최대한 줄이면서 단일 책임을 가져가려는 모습 좋네요 👍


private fun setupViewModel() {
termsAgreementViewModel.agreementStatus.collectWithLifecycle(viewLifecycleOwner) { status ->
updateAgreementIcons(status)
updateAllAgreementIcon(status.values.all { it })
}

termsAgreementViewModel.isRequiredAgreementsChecked.collectWithLifecycle(viewLifecycleOwner) {
updateCompleteButtonState(it)
}
}

private fun updateAgreementIcons(status: Map<AgreementType, Boolean>) {
binding.apply {
ivTermsAgreementService.setImageResource(getToggleIcon(status[AgreementType.SERVICE] == true))
ivTermsAgreementPrivacy.setImageResource(getToggleIcon(status[AgreementType.PRIVACY] == true))
ivTermsAgreementMarketing.setImageResource(getToggleIcon(status[AgreementType.MARKETING] == true))
}
}

private fun getToggleIcon(isChecked: Boolean): Int {
return if (isChecked) ic_terms_agreement_selected else ic_terms_agreement_unselected
}

private fun updateAllAgreementIcon(isChecked: Boolean) {
binding.ivTermsAgreementAll.setImageResource(
if (isChecked) ic_terms_agreement_selected else ic_terms_agreement_unselected,
)
}

private fun updateCompleteButtonState(isEnabled: Boolean) {
binding.btnTermsAgreementComplete.setBackgroundResource(
when (isEnabled) {
true -> bg_novel_rating_date_primary_100_radius_12dp
false -> bg_profile_edit_gray_70_radius_12dp
},
)
}

private fun updateCompleteButtonText() {
val isFromHome = arguments?.getBoolean(IS_FROM_HOME_TAG, false) ?: false

binding.btnTermsAgreementComplete.text =
if (isFromHome) getString(string_terms_agreement_complete)
else getString(string_terms_agreement_next)
}

companion object {
private const val IS_FROM_HOME_TAG = "IS_FROM_HOME"

fun newInstance(isFromHome: Boolean = false): TermsAgreementDialogBottomSheet {
return TermsAgreementDialogBottomSheet().apply {
arguments = bundleOf(IS_FROM_HOME_TAG to isFromHome)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.into.websoso.ui.termsAgreement

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.into.websoso.ui.termsAgreement.model.AgreementType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import javax.inject.Inject

@HiltViewModel
class TermsAgreementViewModel @Inject constructor() : ViewModel() {

private val _agreementStatus = MutableStateFlow(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: 전반적으로 StateFlow를 적용하신 이유가 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stateflow를 사용한 이유는 최신 상태를 유지하면서도 flow 기반의 비동기 처리가 가능하기 때문입니다 livedata는 생명주기가 활성 상태 이상일 때만 데이터를 전달하고 비활성 상태일 때는 업데이트를 보류하게 됩니다! 하지만 약관 동의 상태는 ui가 보이지 않더라도 계속 유지되어야해서 livedata보다는 stateflow가 더 적절하다고 판단했습니다!

Copy link
Member

@s9hn s9hn Feb 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㄴ 사실 현재 코드로는 StateFlow를 사용한다고 해서 얻는 이점은 없어요! StateFlow 또한 결국 View Lifecycle에 의존시켜야하고, 현재 구현상 별다른 Flow 비동기처리를 하고 있지 않기 때문입니다.
웬만해선 두 API (LiveData, Flow) 모두 같은 기능 구현이 가능하기도 합니다!
해당 질문은 면접 단골질문으로 각자 좀 더 공부해보면 좋을 것 같아요 !

mapOf(
AgreementType.SERVICE to false,
AgreementType.PRIVACY to false,
AgreementType.MARKETING to false,
),
)
val agreementStatus: StateFlow<Map<AgreementType, Boolean>> = _agreementStatus.asStateFlow()

val isAllChecked: StateFlow<Boolean> = agreementStatus
.map { it.values.all { checked -> checked } }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: stateIn 확장함수가 필요한가요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 전체동의를 눌렀을 때 밑에 약관동의 항목 3가지 모두가 체크상태로 변경되어야 하고 반대로 개별적으로 체크가 모두 되어지는 경우에도 전체동의가 활성화가 되어야해서 즉각적으로 반응하기 위해서 사용을 했는데 다른 대안이나 불필요하다고 여겨지는 부분이 있으신가요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreementStatus를 활용해 전체가 true인지 확인하는 로직으로 보여집니다.
이미 agreementStatus는 핫플로우로 공개되어 있으며 이를 UI에서 수집하는 것으로 보여집니다.
UI에서 해당 분기작업을 수행하는 것은 어떤가요?
저라면,
agreementStatus.collect {
// 전체가 true인지
// else 단건 on/off
}
이렇게하면 하나의 StateFlow - collect 파이프라인을 줄일 수 있을 것 같아요!


val isRequiredAgreementsChecked: StateFlow<Boolean> = agreementStatus
.map { isRequiredAgreementChecked(it) }
.stateIn(viewModelScope, SharingStarted.Lazily, false)

private fun isRequiredAgreementChecked(status: Map<AgreementType, Boolean>): Boolean {
return status[AgreementType.SERVICE] == true && status[AgreementType.PRIVACY] == true
}

fun updateTermsAgreementsAll() {
val newStatus = _agreementStatus.value.values.any { !it }
_agreementStatus.update { it.mapValues { _ -> newStatus } }
}

fun updateTermsAgreements(agreementType: AgreementType) {
_agreementStatus.value[agreementType]?.let { currentValue ->
_agreementStatus.value = _agreementStatus.value.toMutableMap().apply {
this[agreementType] = !currentValue
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.into.websoso.ui.termsAgreement.model

enum class AgreementType {
SERVICE,
PRIVACY,
MARKETING,
}
13 changes: 13 additions & 0 deletions app/src/main/res/drawable/ic_terms_agreement_selected.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,0C5.373,0 0,5.373 0,12C0,18.627 5.373,24 12,24C18.627,24 24,18.627 24,12C24,5.373 18.627,0 12,0Z"
android:fillColor="#6A5DFD"/>
<path
android:pathData="M18.472,7.265C18.663,7.456 18.77,7.715 18.77,7.985C18.77,8.254 18.663,8.513 18.472,8.704L10.845,16.332C10.744,16.433 10.624,16.513 10.493,16.567C10.361,16.622 10.22,16.65 10.077,16.65C9.934,16.65 9.793,16.622 9.662,16.567C9.53,16.513 9.41,16.433 9.309,16.332L5.52,12.543C5.422,12.449 5.345,12.337 5.292,12.212C5.238,12.088 5.21,11.955 5.209,11.819C5.208,11.684 5.234,11.55 5.285,11.425C5.336,11.3 5.411,11.187 5.507,11.091C5.603,10.995 5.716,10.92 5.841,10.869C5.966,10.818 6.1,10.792 6.235,10.793C6.371,10.794 6.504,10.822 6.628,10.876C6.752,10.929 6.865,11.007 6.959,11.104L10.077,14.222L17.033,7.265C17.127,7.171 17.24,7.096 17.363,7.044C17.486,6.993 17.619,6.967 17.753,6.967C17.886,6.967 18.019,6.993 18.142,7.044C18.266,7.096 18.378,7.171 18.472,7.265Z"
android:fillColor="#F1EFFF"
android:fillType="evenOdd"/>
</vector>
14 changes: 14 additions & 0 deletions app/src/main/res/drawable/ic_terms_agreement_unselected.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,0C18.627,0 24,5.373 24,12C24,18.627 18.627,24 12,24C5.373,24 0,18.627 0,12C0,5.373 5.373,0 12,0Z"
android:fillColor="#DDDDE3"
android:fillType="evenOdd"/>
<path
android:pathData="M18.473,7.265C18.664,7.456 18.771,7.715 18.771,7.985C18.771,8.254 18.664,8.513 18.473,8.704L10.846,16.332C10.745,16.433 10.625,16.513 10.493,16.567C10.362,16.622 10.221,16.65 10.078,16.65C9.935,16.65 9.794,16.622 9.663,16.567C9.531,16.513 9.411,16.433 9.31,16.332L5.521,12.543C5.423,12.449 5.346,12.337 5.293,12.212C5.239,12.088 5.211,11.955 5.21,11.819C5.209,11.684 5.235,11.55 5.286,11.425C5.337,11.3 5.412,11.187 5.508,11.091C5.604,10.995 5.717,10.92 5.842,10.869C5.967,10.818 6.101,10.792 6.236,10.793C6.372,10.794 6.505,10.822 6.629,10.876C6.753,10.929 6.866,11.007 6.96,11.104L10.078,14.222L17.034,7.265C17.128,7.171 17.24,7.096 17.364,7.044C17.487,6.993 17.62,6.967 17.754,6.967C17.887,6.967 18.02,6.993 18.143,7.044C18.267,7.096 18.379,7.171 18.473,7.265Z"
android:fillColor="#EEEEF2"
android:fillType="evenOdd"/>
</vector>
Loading