Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
26242cc
refactor: 로그인 이벤트 통합
stopstone Sep 18, 2025
1b72cd6
feat: 약관 동의 바텀시트 UI 구성
stopstone Sep 18, 2025
779b88b
refactor: 체크박스 클릭 시 리플 효과 제거
stopstone Sep 18, 2025
f2b98ac
feat: 약관 동의 체크 버튼 구현
stopstone Sep 18, 2025
a8217fc
feat: 로그인 성공 시 약관 동의 화면 표시
stopstone Sep 18, 2025
5eebc95
refactor: 로그인 이벤트 통합
stopstone Sep 18, 2025
a897fff
feat: 약관 동의 바텀시트 UI 구성
stopstone Sep 18, 2025
f9d53ca
refactor: 체크박스 클릭 시 리플 효과 제거
stopstone Sep 18, 2025
0315e36
feat: 약관 동의 체크 버튼 구현
stopstone Sep 18, 2025
e779a6c
feat: 로그인 성공 시 약관 동의 화면 표시
stopstone Sep 18, 2025
ea3f273
refactor: 이용약관 동의 항목 UI 개선
stopstone Sep 25, 2025
7bb890f
feat: 약관 동의 화면 웹뷰 연동
stopstone Sep 25, 2025
3e10513
refactor: TermType 클래스 분리
stopstone Sep 25, 2025
2a67004
refactor: 바텀 시트 드래그 핸들 색상 변경
stopstone Sep 25, 2025
eb09411
refactor: 개인정보 동의 바텀시트 문자열 리소스 적용
stopstone Sep 25, 2025
aa0e2d3
fix: PrivacyBottomSheet가 웹뷰에서 돌아왔을 때 내려가는 현상 수정
stopstone Sep 25, 2025
dc1891b
refactor: 개인정보처리방침 바텀시트 약관 항목 모델 분리
stopstone Sep 26, 2025
308dcd6
Merge remote-tracking branch 'origin/feat/login-privacy' into feat/lo…
stopstone Sep 26, 2025
fa024c7
refactor: 약관 동의 항목 생성 로직 개선
stopstone Sep 27, 2025
14f66ab
fix: 로그인 실패 시 에러 스낵바 표시
stopstone Sep 27, 2025
d73e53d
refactor: delay 대신 콜백을 활용하여 웹뷰 이동
stopstone Sep 27, 2025
7a592e1
feat: 웹뷰 로딩 중 프로그레스 바 표시
stopstone Sep 27, 2025
2125d54
refactor: `PrivacyBottomSheet`의 상태 호이스팅 형태로 수정
stopstone Sep 28, 2025
3ef8a7a
refactor: PrivacyBottomSheet 컨텐츠 분리 및 Preview 추가
stopstone Sep 28, 2025
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 @@ -25,21 +25,57 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.alarmy.near.R
import com.alarmy.near.model.ProviderType
import com.alarmy.near.presentation.feature.login.model.TermType
import com.alarmy.near.presentation.ui.theme.NearTheme

@Composable
internal fun LoginRoute(
onNavigateToHome: () -> Unit,
onNavigateToWebView: (title: String, url: String) -> Unit,
onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val showPrivacyBottomSheet by viewModel.showPrivacyBottomSheet.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current

// 약관 제목을 미리 가져옴
val termsTitles =
mapOf(
TermType.SERVICE_TERMS to stringResource(TermType.SERVICE_TERMS.titleRes),
TermType.PRIVACY_COLLECTION to stringResource(TermType.PRIVACY_COLLECTION.titleRes),
TermType.PRIVACY_POLICY to stringResource(TermType.PRIVACY_POLICY.titleRes),
)

// 웹뷰에서 돌아올 때 바텀시트 복원
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.restoreBottomSheetIfNeeded()
}
}

LaunchedEffect(Unit) {
viewModel.loginSuccessEvent.collect {
onNavigateToHome()
viewModel.event.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> {
onNavigateToHome()
}

is LoginEvent.ShowTermsDetail -> {
val title = termsTitles[event.termType] ?: ""
onNavigateToWebView(title, event.termType.url)
}

is LoginEvent.ShowError -> {
onShowErrorSnackBar(event.throwable)
}
}
}
}

Expand All @@ -49,6 +85,23 @@ internal fun LoginRoute(
viewModel.performLogin(providerType)
},
)

// 개인정보 동의 바텀시트
PrivacyConsentBottomSheet(
isVisible = showPrivacyBottomSheet,
onDismiss = {
viewModel.dismissPrivacyBottomSheet()
},
onConsentComplete = {
viewModel.onPrivacyConsentComplete()
},
onTermsClick = { termType ->
viewModel.markNavigatedToWebView()
val title = termsTitles[termType] ?: ""
onNavigateToWebView(title, termType.url)
},
viewModel = viewModel,
)
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.alarmy.near.data.repository.AuthRepository
import com.alarmy.near.model.ProviderType
import com.alarmy.near.presentation.feature.login.model.TermType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
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.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand All @@ -23,45 +27,185 @@ class LoginViewModel
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

// 에러 이벤트 관리
private val _errorEvent = Channel<Throwable?>()
val errorEvent = _errorEvent.receiveAsFlow()
// 이벤트 관리
private val _event = Channel<LoginEvent>()
val event = _event.receiveAsFlow()

// 로그인 성공 이벤트 관리
private val _loginSuccessEvent = Channel<Unit>()
val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow()
// 로그인 화면 상태 관리
private val _loginState = MutableStateFlow(LoginState())
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()

// 개별 상태들을 편의를 위해 노출
val termsAgreementState: StateFlow<TermsAgreementState> =
_loginState
.map { it.termsAgreementState }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
TermsAgreementState(),
)
val showPrivacyBottomSheet: StateFlow<Boolean> =
_loginState
.map { it.showPrivacyBottomSheet }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
false,
)

/**
* 소셜 로그인 수행
*/
fun performLogin(providerType: ProviderType) {
viewModelScope.launch {
updateLoadingState(isLoading = true)

authRepository.performSocialLogin(providerType)
authRepository
.performSocialLogin(providerType)
.onSuccess {
updateLoadingState(isLoading = false)
_loginSuccessEvent.send(Unit)
}
.onFailure { exception ->
_loginState.value = _loginState.value.copy(showPrivacyBottomSheet = true)
}.onFailure { exception ->
updateLoadingState(isLoading = false)
_errorEvent.send(exception)
_event.send(LoginEvent.ShowError(exception))
}
}
}

/**
* 개인정보 동의 완료 처리
*/
fun onPrivacyConsentComplete() {
viewModelScope.launch {
_loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false)
_event.send(LoginEvent.NavigateToHome)
}
}

/**
* 프라이버시 바텀시트 닫기
*/
fun dismissPrivacyBottomSheet() {
_loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false)
}

/**
* 로딩 상태 업데이트
*/
private fun updateLoadingState(isLoading: Boolean) {
_uiState.value = _uiState.value.copy(isLoading = isLoading)
}

/**
* 약관 전체 동의 토글
*/
fun toggleAllTermsAgreement() {
val currentTermsState = _loginState.value.termsAgreementState
val newAgreedState = !currentTermsState.isAllAgreed

val updatedTermsState =
currentTermsState.copy(
isAllAgreed = newAgreedState,
isServiceTermsAgreed = newAgreedState,
isPrivacyCollectionAgreed = newAgreedState,
isPrivacyPolicyAgreed = newAgreedState,
)

_loginState.value = _loginState.value.copy(termsAgreementState = updatedTermsState)
}

/**
* 개별 약관 동의 토글
*/
fun toggleIndividualTermsAgreement(termType: TermType) {
val currentTermsState = _loginState.value.termsAgreementState
val newTermsState =
when (termType) {
TermType.SERVICE_TERMS -> currentTermsState.copy(isServiceTermsAgreed = !currentTermsState.isServiceTermsAgreed)
TermType.PRIVACY_COLLECTION ->
currentTermsState.copy(
isPrivacyCollectionAgreed = !currentTermsState.isPrivacyCollectionAgreed,
)

TermType.PRIVACY_POLICY -> currentTermsState.copy(isPrivacyPolicyAgreed = !currentTermsState.isPrivacyPolicyAgreed)
}

// 모든 개별 약관이 동의되었는지 확인하여 전체 동의 상태 업데이트
val isAllIndividualAgreed =
newTermsState.isServiceTermsAgreed &&
newTermsState.isPrivacyCollectionAgreed &&
newTermsState.isPrivacyPolicyAgreed

val updatedTermsState = newTermsState.copy(isAllAgreed = isAllIndividualAgreed)
_loginState.value = _loginState.value.copy(termsAgreementState = updatedTermsState)
}

/**
* 약관 상세 보기 시 웹뷰로 이동했음을 표시
*/
fun markNavigatedToWebView() {
_loginState.value = _loginState.value.copy(hasNavigatedToWebView = true)
}

/**
* 웹뷰에서 돌아온 후 바텀시트 복원
*/
fun restoreBottomSheetIfNeeded() {
val currentState = _loginState.value
if (currentState.hasNavigatedToWebView && !currentState.showPrivacyBottomSheet) {
_loginState.value =
currentState.copy(
showPrivacyBottomSheet = true,
hasNavigatedToWebView = false,
)
}
}
}

/**
* 로그인 화면 통합 상태
*/
data class LoginState(
val termsAgreementState: TermsAgreementState = TermsAgreementState(),
val hasNavigatedToWebView: Boolean = false,
val showPrivacyBottomSheet: Boolean = false,
)

/**
* 로그인 화면 UI 상태
*/
data class LoginUiState(
val isLoading: Boolean = false,
val hasError: Boolean = false,
)

/**
* 약관 동의 상태
*/
data class TermsAgreementState(
val isAllAgreed: Boolean = false,
val isServiceTermsAgreed: Boolean = false,
val isPrivacyCollectionAgreed: Boolean = false,
val isPrivacyPolicyAgreed: Boolean = false,
) {
/**
* 모든 필수 약관에 동의했는지 확인
*/
val isAllRequiredTermsAgreed: Boolean
get() = isServiceTermsAgreed && isPrivacyCollectionAgreed && isPrivacyPolicyAgreed
}

/*
* 로그인 화면 이벤트 관리
*
* */
sealed class LoginEvent {
object NavigateToHome : LoginEvent()

data class ShowTermsDetail(
val termType: TermType,
) : LoginEvent()

data class ShowError(
val throwable: Throwable?,
) : LoginEvent()
}
Loading