diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt index c231a9c3..dc9c0c79 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt @@ -25,21 +25,58 @@ 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 termsAgreementState by viewModel.termsAgreementState.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) + } + } } } @@ -49,6 +86,29 @@ internal fun LoginRoute( viewModel.performLogin(providerType) }, ) + + // 개인정보 동의 바텀시트 + PrivacyConsentBottomSheet( + isVisible = showPrivacyBottomSheet, + termsAgreementState = termsAgreementState, + onDismiss = { + viewModel.dismissPrivacyBottomSheet() + }, + onConsentComplete = { + viewModel.onPrivacyConsentComplete() + }, + onToggleAllTerms = { + viewModel.toggleAllTermsAgreement() + }, + onToggleIndividualTerms = { termType -> + viewModel.toggleIndividualTermsAgreement(termType) + }, + onTermsClick = { termType -> + viewModel.markNavigatedToWebView() + val title = termsTitles[termType] ?: "" + onNavigateToWebView(title, termType.url) + }, + ) } @Composable diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt index 5f9a9c68..6df56eca 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -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 @@ -23,13 +27,31 @@ class LoginViewModel private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() - // 에러 이벤트 관리 - private val _errorEvent = Channel() - val errorEvent = _errorEvent.receiveAsFlow() + // 이벤트 관리 + private val _event = Channel() + val event = _event.receiveAsFlow() - // 로그인 성공 이벤트 관리 - private val _loginSuccessEvent = Channel() - val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow() + // 로그인 화면 상태 관리 + private val _loginState = MutableStateFlow(LoginState()) + val loginState: StateFlow = _loginState.asStateFlow() + + // 개별 상태들을 편의를 위해 노출 + val termsAgreementState: StateFlow = + _loginState + .map { it.termsAgreementState } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + TermsAgreementState(), + ) + val showPrivacyBottomSheet: StateFlow = + _loginState + .map { it.showPrivacyBottomSheet } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + false, + ) /** * 소셜 로그인 수행 @@ -37,27 +59,117 @@ class LoginViewModel 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 상태 */ @@ -65,3 +177,35 @@ 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() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/PrivacyBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/PrivacyBottomSheet.kt new file mode 100644 index 00000000..198dcb85 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/PrivacyBottomSheet.kt @@ -0,0 +1,251 @@ +package com.alarmy.near.presentation.feature.login + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.login.components.NearBottomSheetDragHandle +import com.alarmy.near.presentation.feature.login.components.TermsAgreementItem +import com.alarmy.near.presentation.feature.login.model.TermType +import com.alarmy.near.presentation.ui.component.button.NearBasicButton +import com.alarmy.near.presentation.ui.component.checkbox.NearBackgroundCheckbox +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacyConsentBottomSheet( + isVisible: Boolean, + termsAgreementState: TermsAgreementState, + onDismiss: () -> Unit, + onConsentComplete: () -> Unit, + onToggleAllTerms: () -> Unit, + onToggleIndividualTerms: (TermType) -> Unit, + onTermsClick: (TermType) -> Unit = {}, +) { + if (isVisible) { + val bottomSheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + val scope = rememberCoroutineScope() + + // 바텀시트를 닫고 약관 페이지로 이동 + val dismissAndNavigateToTerms = { termType: TermType -> + scope.launch { + bottomSheetState.hide() + onTermsClick(termType) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + dragHandle = { NearBottomSheetDragHandle() }, + containerColor = NearTheme.colors.WHITE_FFFFFF, + contentColor = NearTheme.colors.BLACK_1A1A1A, + ) { + PrivacyConsentBottomSheetContent( + termsAgreementState = termsAgreementState, + onToggleAllTerms = onToggleAllTerms, + onToggleIndividualTerms = onToggleIndividualTerms, + onConsentComplete = onConsentComplete, + onShowTermsDetail = { termType -> + dismissAndNavigateToTerms(termType) + }, + ) + } + } +} + +@Composable +private fun PrivacyConsentBottomSheetContent( + termsAgreementState: TermsAgreementState, + onToggleAllTerms: () -> Unit, + onToggleIndividualTerms: (TermType) -> Unit, + onConsentComplete: () -> Unit, + onShowTermsDetail: (TermType) -> Unit, +) { + Column( + modifier = Modifier.padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 바텀시트 제목 + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.privacy_consent_title), + style = NearTheme.typography.B1_16_BOLD, + textAlign = TextAlign.Start, + ) + + Spacer(modifier = Modifier.size(24.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + shape = RoundedCornerShape(12.dp), + ).onNoRippleClick { onToggleAllTerms() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 전체 체크 + TermsAllCheckAgreementSection( + termsAgreementState = termsAgreementState, + onToggleAllTerms = onToggleAllTerms, + ) + } + + Spacer(modifier = Modifier.size(16.dp)) + + // 개별 약관 동의 + TermsAgreementSection( + termsAgreementState = termsAgreementState, + onToggleIndividualTerms = onToggleIndividualTerms, + onShowTermsDetail = onShowTermsDetail, + ) + + Spacer(modifier = Modifier.size(32.dp)) + + NearBasicButton( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 16.dp), + onClick = onConsentComplete, + enabled = termsAgreementState.isAllRequiredTermsAgreed, + ) { + Text( + text = stringResource(R.string.privacy_consent_signup_button), + style = NearTheme.typography.B1_16_BOLD, + ) + } + + Spacer(modifier = Modifier.size(24.dp)) + } +} + +@Composable +private fun TermsAllCheckAgreementSection( + termsAgreementState: TermsAgreementState, + onToggleAllTerms: () -> Unit, +) { + NearBackgroundCheckbox( + checked = termsAgreementState.isAllAgreed, + onCheckedChange = { onToggleAllTerms() }, + ) + + Spacer(modifier = Modifier.size(8.dp)) + + Text( + text = stringResource(R.string.privacy_consent_all_agreement), + style = NearTheme.typography.B2_14_BOLD, + ) +} + +@Composable +private fun TermsAgreementSection( + termsAgreementState: TermsAgreementState, + onToggleIndividualTerms: (TermType) -> Unit, + onShowTermsDetail: (TermType) -> Unit, +) { + val requiredPrefix = stringResource(R.string.privacy_consent_required_prefix) + + // 약관 타입 목록을 상수로 정의하여 리컴포지션 시 재생성 방지 + val terms = + remember { + listOf( + TermType.SERVICE_TERMS, + TermType.PRIVACY_COLLECTION, + TermType.PRIVACY_POLICY, + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + shape = RoundedCornerShape(12.dp), + ).padding(horizontal = 16.dp, vertical = 4.dp), + ) { + terms.forEachIndexed { index, termType -> + val isAgreed = + when (termType) { + TermType.SERVICE_TERMS -> termsAgreementState.isServiceTermsAgreed + TermType.PRIVACY_COLLECTION -> termsAgreementState.isPrivacyCollectionAgreed + TermType.PRIVACY_POLICY -> termsAgreementState.isPrivacyPolicyAgreed + } + TermsAgreementItem( + text = "$requiredPrefix ${stringResource(termType.titleRes)}", + isChecked = isAgreed, + onCheckedChange = { onToggleIndividualTerms(termType) }, + onclick = { onShowTermsDetail(termType) }, + showDivider = index < terms.size - 1, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PrivacyConsentBottomSheetContentPreview() { + NearTheme { + PrivacyConsentBottomSheetContent( + termsAgreementState = + TermsAgreementState( + isAllAgreed = false, + isServiceTermsAgreed = true, + isPrivacyCollectionAgreed = false, + isPrivacyPolicyAgreed = true, + ), + onToggleAllTerms = {}, + onToggleIndividualTerms = {}, + onConsentComplete = {}, + onShowTermsDetail = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PrivacyConsentBottomSheetAllAgreedContentPreview() { + NearTheme { + PrivacyConsentBottomSheetContent( + termsAgreementState = + TermsAgreementState( + isAllAgreed = true, + isServiceTermsAgreed = true, + isPrivacyCollectionAgreed = true, + isPrivacyPolicyAgreed = true, + ), + onToggleAllTerms = {}, + onToggleIndividualTerms = {}, + onConsentComplete = {}, + onShowTermsDetail = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/components/NearBottomSheetDragHandle.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/components/NearBottomSheetDragHandle.kt new file mode 100644 index 00000000..1d3342b9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/components/NearBottomSheetDragHandle.kt @@ -0,0 +1,19 @@ +package com.alarmy.near.presentation.feature.login.components + +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun NearBottomSheetDragHandle() { + BottomSheetDefaults.DragHandle( + color = NearTheme.colors.BLACK_1A1A1A.copy( + alpha = 0.1f, + ), + width = 32.dp, + height = 6.dp, + ) +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/components/TermsAgreementItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/components/TermsAgreementItem.kt new file mode 100644 index 00000000..9b1a03cc --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/components/TermsAgreementItem.kt @@ -0,0 +1,79 @@ +package com.alarmy.near.presentation.feature.login.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.component.checkbox.NearCheckbox +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun TermsAgreementItem( + text: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + onclick: () -> Unit, + showDivider: Boolean, +) { + Row( + modifier = + Modifier + .padding(vertical = 14.dp) + .onNoRippleClick(onclick), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // 체크박스 클릭 시 체크 상태만 변경 + NearCheckbox( + checked = isChecked, + onCheckedChange = onCheckedChange, + ) + + Spacer(modifier = Modifier.size(8.dp)) + + // 텍스트 영역 클릭 시 웹뷰로 이동 + Text( + text = text, + style = NearTheme.typography.B2_14_MEDIUM, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.ic_front_24_gray), + contentDescription = null, + ) + } + + if (showDivider) { + HorizontalDivider( + color = NearTheme.colors.GRAY03_EBEBEB, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TermsAgreementItemPreview() { + NearTheme { + TermsAgreementItem( + text = "개인정보동의~", + isChecked = true, + onCheckedChange = {}, + onclick = {}, + showDivider = true, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/model/TermType.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/model/TermType.kt new file mode 100644 index 00000000..f5a4e806 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/model/TermType.kt @@ -0,0 +1,26 @@ +package com.alarmy.near.presentation.feature.login.model + +import androidx.annotation.StringRes +import com.alarmy.near.BuildConfig +import com.alarmy.near.R + +/** + * 약관 및 정책 타입 + */ +enum class TermType( + @StringRes val titleRes: Int, + val url: String, +) { + SERVICE_TERMS( + titleRes = R.string.terms_service_agreed, + url = BuildConfig.SERVICE_AGREED_TERMS_URL, + ), + PRIVACY_COLLECTION( + titleRes = R.string.terms_personal_info, + url = BuildConfig.PERSONAL_INFO_TERMS_URL, + ), + PRIVACY_POLICY( + titleRes = R.string.terms_privacy_policy, + url = BuildConfig.PRIVACY_POLICY_TERMS_URL, + ), +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/model/TermsItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/model/TermsItem.kt new file mode 100644 index 00000000..a425ccbc --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/model/TermsItem.kt @@ -0,0 +1,10 @@ +package com.alarmy.near.presentation.feature.login.model + +/** + * 약관 항목 정보 + */ +data class TermsItem( + val title: String, + val termType: TermType, + val isAgreed: Boolean, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt index f2d70ce7..7711de6a 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt @@ -18,11 +18,14 @@ fun NavController.navigateToLogin(navOptions: NavOptions? = null) { // 로그인 화면 NavGraph 정의 fun NavGraphBuilder.loginNavGraph( onNavigateToHome: () -> Unit, + onNavigateToTerms: (title: String, url: String) -> Unit, onShowErrorSnackBar: (throwable: Throwable?) -> Unit, ) { composable { LoginRoute( onNavigateToHome = onNavigateToHome, + onNavigateToWebView = onNavigateToTerms, + onShowErrorSnackBar = onShowErrorSnackBar, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index bef58d32..b3e9ed1a 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -9,7 +9,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import com.alarmy.near.presentation.feature.contact.navigation.CONTACT_SELECTION_COMPLETE_KEY -import com.alarmy.near.presentation.feature.contact.navigation.RouteContact import com.alarmy.near.presentation.feature.contact.navigation.contactNavGraph import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToFriendProfile @@ -62,7 +61,6 @@ internal fun NearNavHost( // 로그인 화면 NavGraph loginNavGraph( - onShowErrorSnackBar = onShowSnackbar, onNavigateToHome = { navController.navigateToHome( navOptions = @@ -71,6 +69,10 @@ internal fun NearNavHost( }, ) }, + onNavigateToTerms = { title, url -> + navController.navigateToWebView(title, url) + }, + onShowErrorSnackBar = onShowSnackbar, ) // 홈 화면 NavGraph @@ -146,30 +148,6 @@ internal fun NearNavHost( navController.popBackStack() }) - // 로그인 화면 NavGraph - loginNavGraph( - onShowErrorSnackBar = onShowSnackbar, - onNavigateToHome = { - navController.navigateToHome( - navOptions = - navOptions { - popUpTo(RouteLogin) { inclusive = true } - }, - ) - }, - ) - - // 홈 화면 NavGraph - homeNavGraph( - onShowErrorSnackBar = onShowSnackbar, - onContactClick = { contactId -> - navController.navigateToFriendProfile(friendId = contactId) - }, - onMyPageClick = {}, - onAlarmClick = {}, - onAddContactClick = {}, - ) - contactNavGraph( onShowErrorSnackBar = onShowSnackbar, onBackClick = { diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/WebViewFrame.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/WebViewFrame.kt index e858f076..56eee955 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/WebViewFrame.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/WebViewFrame.kt @@ -1,20 +1,26 @@ package com.alarmy.near.presentation.ui.component +import android.graphics.Bitmap import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.alarmy.near.presentation.ui.component.appbar.NearCancelTopAppBar +import com.alarmy.near.presentation.ui.theme.NearTheme @Composable fun WebViewFrame( @@ -25,6 +31,7 @@ fun WebViewFrame( ) { var canGoBack by remember { mutableStateOf(false) } var webView: WebView? by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } BackHandler(enabled = canGoBack) { webView?.goBack() @@ -32,38 +39,66 @@ fun WebViewFrame( NearFrame { NearCancelTopAppBar( - modifier = Modifier.padding(horizontal = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), title = title, onCancelClick = onNavigateBack, ) - AndroidView( - modifier = modifier.fillMaxSize(), - factory = { context -> - WebView(context).apply { - webView = this - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - // 페이지 로드 완료 시 뒤로가기 가능 상태 업데이트 - canGoBack = view?.canGoBack() ?: false + Box( + modifier = modifier.weight(1f), + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + webView = this + webViewClient = + object : WebViewClient() { + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + super.onPageStarted(view, url, favicon) + isLoading = true + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + super.onPageFinished(view, url) + canGoBack = view?.canGoBack() ?: false + isLoading = false + } + } + settings.apply { + domStorageEnabled = true + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false } + loadUrl(url) } - settings.apply { - domStorageEnabled = true - mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW - loadWithOverviewMode = true - useWideViewPort = true - builtInZoomControls = true - displayZoomControls = false - } - loadUrl(url) - } - }, - update = { view -> - // WebView 업데이트 시 뒤로가기 가능 상태 동기화 - canGoBack = view.canGoBack() + }, + update = { view -> + // WebView 업데이트 시 뒤로가기 가능 상태 동기화 + canGoBack = view.canGoBack() + }, + ) + + // 로딩 중일 때 중앙에 로딩 인디케이터 표시 + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = NearTheme.colors.BLUE01_5AA2E9, + ) } - ) + } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/checkbox/NearCheckbox.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/checkbox/NearCheckbox.kt index e2ce96aa..70cd6b1c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/checkbox/NearCheckbox.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/checkbox/NearCheckbox.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.alarmy.near.R +import com.alarmy.near.presentation.ui.extension.onNoRippleClick @Composable fun NearCheckbox( @@ -21,7 +22,7 @@ fun NearCheckbox( ) { Image( modifier = - modifier.clickable( + modifier.onNoRippleClick( onClick = { onCheckedChange(!checked) }, ), painter = diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 8dfc484d..e2040381 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -131,6 +131,12 @@ 서비스 이용약관 개인정보 수집 및 이용동의 개인정보 처리방침 + + + 서비스 약관 동의 + 약관 전체 동의 + [필수] + 가입 카카오