diff --git a/.github/workflows/pr_checker.yml b/.github/workflows/pr_checker.yml index aa825f41..0ccfa216 100644 --- a/.github/workflows/pr_checker.yml +++ b/.github/workflows/pr_checker.yml @@ -38,10 +38,12 @@ jobs: NAVER_MAP_CLIENT_ID: ${{ secrets.NAVER_MAP_CLIENT_ID }} NAVER_MAP_CLIENT_SECRET: ${{ secrets.NAVER_MAP_CLIENT_SECRET }} GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + PRIVACY_POLICY: ${{ secrets.PRIVACY_POLICY }} run: | echo NAVER_MAP_CLIENT_ID=$NAVER_MAP_CLIENT_ID > ./local.properties echo NAVER_MAP_CLIENT_SECRET=$NAVER_MAP_CLIENT_SECRET >> ./local.properties echo GOOGLE_WEB_CLIENT_ID=$GOOGLE_WEB_CLIENT_ID >> ./local.properties + echo PRIVACY_POLICY=$PRIVACY_POLICY >> ./local.properties - name: Create google-services.json run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./app/google-services.json diff --git a/core/auth/src/main/java/com/boostcamp/mapisode/auth/GoogleOauth.kt b/core/auth/src/main/java/com/boostcamp/mapisode/auth/GoogleOauth.kt index 82e74580..b9e8d58e 100644 --- a/core/auth/src/main/java/com/boostcamp/mapisode/auth/GoogleOauth.kt +++ b/core/auth/src/main/java/com/boostcamp/mapisode/auth/GoogleOauth.kt @@ -7,6 +7,7 @@ import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest import androidx.credentials.exceptions.GetCredentialCancellationException import com.boostcamp.mapisode.model.AuthData +import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.firebase.auth.AuthResult @@ -65,17 +66,42 @@ class GoogleOauth(private val context: Context) { } } + suspend fun googleSignOut() = FirebaseAuth.getInstance().signOut() + + suspend fun isUserLoggedIn(): Boolean = FirebaseAuth.getInstance().currentUser != null + + suspend fun deleteCurrentUser() { + try { + val credentialManager = initializeCredentialManager() + val googleIdOption = GetGoogleIdOption.Builder().setFilterByAuthorizedAccounts(true) + .setNonce(generateNonce()).build() + val request: GetCredentialRequest = + GetCredentialRequest.Builder().addCredentialOption(googleIdOption).build() + + val resultCredential = credentialManager.getCredential(context, request).credential + val validatedCredential = validateCredential(resultCredential) + + val authCredential = GoogleAuthProvider.getCredential(validatedCredential.idToken, null) + + FirebaseAuth.getInstance().currentUser?.let { user -> + user.reauthenticate(authCredential).await() + user.delete().await() + } ?: throw Exception("현재 로그인된 사용자가 없습니다.") + } catch (e: Exception) { + throw Exception("계정 삭제 실패: ${e.message}") + } + } + private fun initializeCredentialManager(): CredentialManager = CredentialManager.create(context) - private fun createGoogleSignInOption(): GetSignInWithGoogleOption = GetSignInWithGoogleOption - .Builder(BuildConfig.GOOGLE_WEB_CLIENT_ID) - .setNonce(generateNonce()) - .build() + private fun createGoogleSignInOption(): GetSignInWithGoogleOption = + GetSignInWithGoogleOption.Builder(BuildConfig.GOOGLE_WEB_CLIENT_ID) + .setNonce(generateNonce()).build() private fun createCredentialRequest(googleIdOption: GetSignInWithGoogleOption): - GetCredentialRequest = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() + GetCredentialRequest = GetCredentialRequest + .Builder() + .addCredentialOption(googleIdOption).build() private fun validateCredential(credential: Credential): GoogleIdTokenCredential { if (credential is CustomCredential && @@ -91,8 +117,7 @@ class GoogleOauth(private val context: Context) { firebaseAuth: FirebaseAuth, googleIdTokenCredential: GoogleIdTokenCredential, ): AuthResult { - val authCredential = - GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null) + val authCredential = GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null) return firebaseAuth.signInWithCredential(authCredential).await() } diff --git a/core/designsystem/src/main/res/drawable/ic_document.xml b/core/designsystem/src/main/res/drawable/ic_document.xml new file mode 100644 index 00000000..4e29172f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_document.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_withdrawal.xml b/core/designsystem/src/main/res/drawable/ic_withdrawal.xml new file mode 100644 index 00000000..e74ce08d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_withdrawal.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/navigation/src/main/java/com/boostcamp/mapisode/navigation/MypageRoute.kt b/core/navigation/src/main/java/com/boostcamp/mapisode/navigation/MypageRoute.kt new file mode 100644 index 00000000..e2641f28 --- /dev/null +++ b/core/navigation/src/main/java/com/boostcamp/mapisode/navigation/MypageRoute.kt @@ -0,0 +1,8 @@ +package com.boostcamp.mapisode.navigation + +import kotlinx.serialization.Serializable + +sealed interface MypageRoute : Route { + @Serializable + data object ProfileEdit : MypageRoute +} diff --git a/data/user/src/main/java/com/boostcamp/mapisode/user/UserRepositoryImpl.kt b/data/user/src/main/java/com/boostcamp/mapisode/user/UserRepositoryImpl.kt index ef8065bc..9a50d5d0 100644 --- a/data/user/src/main/java/com/boostcamp/mapisode/user/UserRepositoryImpl.kt +++ b/data/user/src/main/java/com/boostcamp/mapisode/user/UserRepositoryImpl.kt @@ -55,4 +55,23 @@ class UserRepositoryImpl @Inject constructor(database: FirebaseFirestore) : User throw Exception("Failed to get user", e) } } + + override suspend fun updateUserNameAndProfileUrl( + uid: String, + userName: String, + profileUrl: String, + ) { + val userDocument = userCollection.document(uid) + + try { + userDocument.update( + mapOf( + "name" to userName, + "profileUrl" to profileUrl, + ), + ).await() + } catch (e: Exception) { + throw Exception("Failed to update user", e) + } + } } diff --git a/domain/user/src/main/java/com/boostcamp/mapisode/user/UserRepository.kt b/domain/user/src/main/java/com/boostcamp/mapisode/user/UserRepository.kt index 533dc82e..6d4b426b 100644 --- a/domain/user/src/main/java/com/boostcamp/mapisode/user/UserRepository.kt +++ b/domain/user/src/main/java/com/boostcamp/mapisode/user/UserRepository.kt @@ -7,4 +7,6 @@ interface UserRepository { suspend fun getUserInfo(uid: String): UserModel suspend fun isUserExist(uid: String): Boolean + + suspend fun updateUserNameAndProfileUrl(uid: String, userName: String, profileUrl: String) } diff --git a/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthIntent.kt b/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthIntent.kt index 0542d6d7..4ca6465d 100644 --- a/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthIntent.kt +++ b/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthIntent.kt @@ -4,11 +4,11 @@ import com.boostcamp.mapisode.auth.GoogleOauth import com.boostcamp.mapisode.ui.base.UiIntent sealed interface AuthIntent : UiIntent { + data object Init : AuthIntent data class OnGoogleSignInClick(val googleOauth: GoogleOauth) : AuthIntent data class OnNicknameChange(val nickname: String) : AuthIntent data class OnProfileUrlchange(val profileUrl: String) : AuthIntent data object OnSignUpClick : AuthIntent - data object OnAutoLogin : AuthIntent data object OnLoginSuccess : AuthIntent data object OnBackClickedInSignUp : AuthIntent } diff --git a/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthRoute.kt b/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthRoute.kt index 531383ef..aa1fc255 100644 --- a/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthRoute.kt +++ b/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthRoute.kt @@ -19,7 +19,7 @@ fun AuthRoute( val googleOauth = GoogleOauth(context) LaunchedEffect(Unit) { - viewModel.onIntent(AuthIntent.OnAutoLogin) + viewModel.onIntent(AuthIntent.Init) } LaunchedEffect(Unit) { diff --git a/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthViewModel.kt b/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthViewModel.kt index 0915bb6e..3ed28b0b 100644 --- a/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthViewModel.kt +++ b/feature/login/src/main/java/com/boostcamp/mapisode/login/AuthViewModel.kt @@ -24,16 +24,20 @@ class AuthViewModel @Inject constructor( override fun onIntent(intent: AuthIntent) { when (intent) { + is AuthIntent.Init -> onInit() is AuthIntent.OnGoogleSignInClick -> handleGoogleSignIn(intent.googleOauth) is AuthIntent.OnNicknameChange -> onNicknameChange(intent.nickname) is AuthIntent.OnProfileUrlchange -> onProfileUrlChange(intent.profileUrl) is AuthIntent.OnSignUpClick -> handleSignUp() - is AuthIntent.OnAutoLogin -> handleAutoLogin() is AuthIntent.OnLoginSuccess -> handleLoginSuccess() is AuthIntent.OnBackClickedInSignUp -> onBackClickedInSignUp() } } + private fun onInit() { + handleAutoLogin() + } + private fun onBackClickedInSignUp() { intent { copy( diff --git a/feature/main/src/main/java/com/boostcamp/mapisode/main/MainNavigator.kt b/feature/main/src/main/java/com/boostcamp/mapisode/main/MainNavigator.kt index acb01483..b460340a 100644 --- a/feature/main/src/main/java/com/boostcamp/mapisode/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/boostcamp/mapisode/main/MainNavigator.kt @@ -21,6 +21,7 @@ import com.boostcamp.mapisode.mygroup.navigation.navigateGroupCreation import com.boostcamp.mapisode.mygroup.navigation.navigateGroupDetail import com.boostcamp.mapisode.mygroup.navigation.navigateGroupEdit import com.boostcamp.mapisode.mygroup.navigation.navigateGroupJoin +import com.boostcamp.mapisode.mypage.navigation.navigateToProfileEdit import com.boostcamp.mapisode.navigation.MainRoute import com.boostcamp.mapisode.navigation.Route @@ -62,6 +63,12 @@ internal class MainNavigator( } } + fun navigateToLogin() { + navController.navigate(startDestination) { + popUpTo(startDestination) { inclusive = true } + } + } + fun navigateToMain() { navController.navigate(MainRoute.Home) } @@ -113,6 +120,10 @@ internal class MainNavigator( navController.navigateGroupEdit(groupId) } + fun navigateToProfileEdit() { + navController.navigateToProfileEdit() + } + private fun popBackStack() { navController.popBackStack() } diff --git a/feature/main/src/main/java/com/boostcamp/mapisode/main/component/MainNavHost.kt b/feature/main/src/main/java/com/boostcamp/mapisode/main/component/MainNavHost.kt index ad66915f..575acc9e 100644 --- a/feature/main/src/main/java/com/boostcamp/mapisode/main/component/MainNavHost.kt +++ b/feature/main/src/main/java/com/boostcamp/mapisode/main/component/MainNavHost.kt @@ -56,7 +56,11 @@ internal fun MainNavHost( }, onEpisodeClick = navigator::navigateToEpisodeDetail, ) - addMyPageNavGraph() + addMyPageNavGraph( + onBackClick = navigator::popBackStackIfNotHome, + onProfileEditClick = navigator::navigateToProfileEdit, + onNavgiatetoLogin = navigator::navigateToLogin, + ) } } } diff --git a/feature/mypage/build.gradle.kts b/feature/mypage/build.gradle.kts index 06645e40..0623c171 100644 --- a/feature/mypage/build.gradle.kts +++ b/feature/mypage/build.gradle.kts @@ -1,7 +1,29 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + plugins { alias(libs.plugins.mapisode.feature) } android { namespace = "com.boostcamp.mapisode.mypage" + + defaultConfig { + val privacyPolicy = + gradleLocalProperties(rootDir, providers).getProperty("PRIVACY_POLICY") ?: "" + if (privacyPolicy.isEmpty()) { + throw GradleException("PRIVACY_POLICY is not set.") + } + buildConfigField("String", "PRIVACY_POLICY", "\"$privacyPolicy\"") + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(libs.bundles.coil) + implementation(libs.androidx.browser) + implementation(projects.core.auth) + implementation(projects.domain.user) } diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/MyPageScreen.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/MyPageScreen.kt deleted file mode 100644 index b7a524f8..00000000 --- a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/MyPageScreen.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.boostcamp.mapisode.mypage - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier - -@Composable -internal fun MyPageRoute() { - MyPageScreen() -} - -@Composable -private fun MyPageScreen() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text(text = "MyPageScreen", style = MaterialTheme.typography.displayMedium) - } -} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/intent/MypageIntent.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/intent/MypageIntent.kt new file mode 100644 index 00000000..8ad21694 --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/intent/MypageIntent.kt @@ -0,0 +1,15 @@ +package com.boostcamp.mapisode.mypage.intent + +import android.content.Context +import com.boostcamp.mapisode.auth.GoogleOauth +import com.boostcamp.mapisode.ui.base.UiIntent + +sealed interface MypageIntent : UiIntent { + data object Init : MypageIntent + data class LogoutClick(val googleOauth: GoogleOauth) : MypageIntent + data object ProfileEditClick : MypageIntent + data class PrivacyPolicyClick(val context: Context, val useCustomTab: Boolean) : MypageIntent + data object WithdrawalClick : MypageIntent + data class ConfirmClick(val googleOauth: GoogleOauth) : MypageIntent + data object TurnOffDialog : MypageIntent +} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/intent/ProfileEditIntent.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/intent/ProfileEditIntent.kt new file mode 100644 index 00000000..ae6d731e --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/intent/ProfileEditIntent.kt @@ -0,0 +1,12 @@ +package com.boostcamp.mapisode.mypage.intent + +import com.boostcamp.mapisode.ui.base.UiIntent + +sealed interface ProfileEditIntent : UiIntent { + data object Init : ProfileEditIntent + data class NameChanged(val nickname: String) : ProfileEditIntent + data class ProfileChanged(val url: String) : ProfileEditIntent + data object PhotopickerClick : ProfileEditIntent + data object EditClick : ProfileEditIntent + data object BackClick : ProfileEditIntent +} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/navigation/MyPageNavigation.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/navigation/MyPageNavigation.kt index d4718002..45cd22d5 100644 --- a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/navigation/MyPageNavigation.kt +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/navigation/MyPageNavigation.kt @@ -4,8 +4,10 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import com.boostcamp.mapisode.mypage.MyPageRoute +import com.boostcamp.mapisode.mypage.screen.MypageRoute +import com.boostcamp.mapisode.mypage.screen.ProfileEditRoute import com.boostcamp.mapisode.navigation.MainRoute +import com.boostcamp.mapisode.navigation.MypageRoute fun NavController.navigateMyPage( navOptions: NavOptions? = null, @@ -13,8 +15,27 @@ fun NavController.navigateMyPage( navigate(MainRoute.Mypage, navOptions) } -fun NavGraphBuilder.addMyPageNavGraph() { +fun NavController.navigateToProfileEdit( + navOptions: NavOptions? = null, +) { + navigate(MypageRoute.ProfileEdit, navOptions) +} + +fun NavGraphBuilder.addMyPageNavGraph( + onBackClick: () -> Unit, + onProfileEditClick: () -> Unit, + onNavgiatetoLogin: () -> Unit, +) { composable { - MyPageRoute() + MypageRoute( + onProfileEditClick = onProfileEditClick, + onNavigateToLogin = onNavgiatetoLogin, + ) + } + + composable { + ProfileEditRoute( + onBackClick = onBackClick, + ) } } diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/screen/MypageScreen.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/screen/MypageScreen.kt new file mode 100644 index 00000000..b31bc88c --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/screen/MypageScreen.kt @@ -0,0 +1,279 @@ +package com.boostcamp.mapisode.mypage.screen + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.clickable +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.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.boostcamp.mapisode.auth.GoogleOauth +import com.boostcamp.mapisode.designsystem.R.drawable.ic_arrow_forward_ios +import com.boostcamp.mapisode.designsystem.R.drawable.ic_document +import com.boostcamp.mapisode.designsystem.R.drawable.ic_edit +import com.boostcamp.mapisode.designsystem.compose.IconSize +import com.boostcamp.mapisode.designsystem.compose.MapisodeDialog +import com.boostcamp.mapisode.designsystem.compose.MapisodeDivider +import com.boostcamp.mapisode.designsystem.compose.MapisodeIcon +import com.boostcamp.mapisode.designsystem.compose.MapisodeIconButton +import com.boostcamp.mapisode.designsystem.compose.MapisodeScaffold +import com.boostcamp.mapisode.designsystem.compose.MapisodeText +import com.boostcamp.mapisode.designsystem.compose.Thickness +import com.boostcamp.mapisode.designsystem.compose.topbar.TopAppBar +import com.boostcamp.mapisode.designsystem.theme.MapisodeTheme +import com.boostcamp.mapisode.mypage.BuildConfig +import com.boostcamp.mapisode.mypage.R +import com.boostcamp.mapisode.mypage.intent.MypageIntent +import com.boostcamp.mapisode.mypage.sideeffect.MypageSideEffect +import com.boostcamp.mapisode.mypage.viewmodel.MypageViewModel + +@Composable +internal fun MypageRoute( + onProfileEditClick: () -> Unit, + onNavigateToLogin: () -> Unit, + viewModel: MypageViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val googleOauth = GoogleOauth(context) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.onIntent(MypageIntent.Init) + } + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + is MypageSideEffect.Idle -> {} + is MypageSideEffect.NavigateToLoginScreen -> onNavigateToLogin() + is MypageSideEffect.NavigateToEditScreen -> onProfileEditClick() + is MypageSideEffect.ShowToast -> { + Toast.makeText( + context, + sideEffect.messageResId, + Toast.LENGTH_SHORT, + ).show() + } + + is MypageSideEffect.OpenPrivacyPolicy -> { + val url = BuildConfig.PRIVACY_POLICY + try { + val customTabsIntent = CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + customTabsIntent.launchUrl(context, Uri.parse(url)) + } catch (e: Exception) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } + } + } + } + } + + if (uiState.showWithdrawalDialog) { + MapisodeDialog( + titleText = stringResource(R.string.mypage_withdrawal_title), + contentText = stringResource(R.string.mypage_withdrawal_message), + confirmText = stringResource(R.string.mypage_withdrawal_confirm), + cancelText = stringResource(R.string.mypage_withdrawal_cancel), + onResultRequest = { isConfirm -> + if (isConfirm) viewModel.onIntent(MypageIntent.ConfirmClick(googleOauth)) + }, + onDismissRequest = { + viewModel.onIntent(MypageIntent.TurnOffDialog) + }, + ) + } + + MypageScreen( + name = uiState.name, + profileUrl = uiState.profileUrl, + onLogoutClick = { viewModel.onIntent(MypageIntent.LogoutClick(googleOauth)) }, + onProfileEditClick = onProfileEditClick, + onPrivacyPolicyClick = { + viewModel.onIntent( + MypageIntent.PrivacyPolicyClick( + context, + true, + ), + ) + }, + onWithdrawalClick = { viewModel.onIntent(MypageIntent.WithdrawalClick) }, + ) +} + +@Composable +private fun MypageScreen( + name: String, + profileUrl: String, + onLogoutClick: () -> Unit, + onProfileEditClick: () -> Unit, + onPrivacyPolicyClick: () -> Unit, + onWithdrawalClick: () -> Unit, + modifier: Modifier = Modifier, +) { + MapisodeScaffold( + modifier = modifier.fillMaxSize(), + isNavigationBarPaddingExist = true, + isStatusBarPaddingExist = true, + topBar = { + Column(modifier = Modifier.fillMaxWidth()) { + TopAppBar( + title = stringResource(R.string.mypage_title), + ) { + MapisodeText( + text = stringResource(R.string.mypage_logout), + modifier = Modifier.clickable { onLogoutClick() }, + style = MapisodeTheme.typography.labelLarge, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + MapisodeDivider(thickness = Thickness.Thin) + } + }, + ) { innerPadding -> + Box( + modifier = modifier + .padding(innerPadding) + .fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = modifier + .wrapContentHeight() + .fillMaxWidth(0.9f), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Profile( + name = name, + profileUrl = profileUrl, + onProfileEditClick = onProfileEditClick, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + MapisodeDivider(thickness = Thickness.Thin) + + Spacer(modifier = Modifier.height(16.dp)) + + MapisodeText( + text = stringResource(R.string.mypage_account), + style = MapisodeTheme.typography.labelLarge, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + MyPageContent( + text = stringResource(R.string.mypage_terms_of_use), + iconId = ic_document, + onClick = onPrivacyPolicyClick, + ) + + Spacer(modifier = Modifier.height(20.dp)) + } + } + } +} + +@Composable +private fun Profile( + name: String, + profileUrl: String, + onProfileEditClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = profileUrl, + contentDescription = stringResource(R.string.mypage_profile_image), + modifier = Modifier + .size(52.dp) + .clip(CircleShape), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + MapisodeText( + text = name, + style = MapisodeTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.weight(1f)) + + MapisodeIconButton( + onClick = onProfileEditClick, + ) { + MapisodeIcon( + id = ic_edit, + iconSize = IconSize.Large, + ) + } + } +} + +@Composable +private fun MyPageContent( + text: String, + iconId: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.wrapContentSize()) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MapisodeIcon( + id = iconId, + iconSize = IconSize.Large, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + MapisodeText( + text = text, + style = MapisodeTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.weight(1f)) + + MapisodeIcon(id = ic_arrow_forward_ios) + } + Spacer(modifier = Modifier.height(16.dp)) + MapisodeDivider(thickness = Thickness.Thin) + } +} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/screen/ProfileEditScreen.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/screen/ProfileEditScreen.kt new file mode 100644 index 00000000..23bd6f19 --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/screen/ProfileEditScreen.kt @@ -0,0 +1,230 @@ +package com.boostcamp.mapisode.mypage.screen + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.boostcamp.mapisode.designsystem.R.drawable +import com.boostcamp.mapisode.designsystem.compose.MapisodeIcon +import com.boostcamp.mapisode.designsystem.compose.MapisodeIconButton +import com.boostcamp.mapisode.designsystem.compose.MapisodeScaffold +import com.boostcamp.mapisode.designsystem.compose.MapisodeText +import com.boostcamp.mapisode.designsystem.compose.MapisodeTextField +import com.boostcamp.mapisode.designsystem.compose.button.MapisodeFilledButton +import com.boostcamp.mapisode.designsystem.compose.button.MapisodeImageButton +import com.boostcamp.mapisode.designsystem.compose.topbar.TopAppBar +import com.boostcamp.mapisode.designsystem.theme.MapisodeTheme +import com.boostcamp.mapisode.mypage.R +import com.boostcamp.mapisode.mypage.intent.ProfileEditIntent +import com.boostcamp.mapisode.mypage.sideeffect.ProfileEditSideEffect +import com.boostcamp.mapisode.mypage.viewmodel.ProfileEditViewModel +import com.boostcamp.mapisode.ui.photopicker.MapisodePhotoPicker + +@Composable +fun ProfileEditRoute( + onBackClick: () -> Unit, + viewModel: ProfileEditViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.onIntent(ProfileEditIntent.Init) + } + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + is ProfileEditSideEffect.NavigateToMypage -> onBackClick() + is ProfileEditSideEffect.ShowToast -> { + Toast.makeText( + context, + sideEffect.messageResId, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + + ProfileEditScreen( + isPhotoPickerClicked = uiState.isPhotoPickerClicked, + onPhotoPickerClick = { viewModel.onIntent(ProfileEditIntent.PhotopickerClick) }, + nickname = uiState.name, + onNicknameChanged = { viewModel.onIntent(ProfileEditIntent.NameChanged(it)) }, + profileUrl = uiState.profileUrl, + onProfileUrlChange = { viewModel.onIntent(ProfileEditIntent.ProfileChanged(it)) }, + onEditClick = { viewModel.onIntent(ProfileEditIntent.EditClick) }, + onBackClick = onBackClick, + ) +} + +@Composable +fun ProfileEditScreen( + isPhotoPickerClicked: Boolean, + onPhotoPickerClick: () -> Unit, + nickname: String, + onNicknameChanged: (String) -> Unit, + profileUrl: String, + onProfileUrlChange: (String) -> Unit, + onEditClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (isPhotoPickerClicked) { + MapisodePhotoPicker( + numOfPhoto = 1, + onPhotoSelected = { selectedPhotos -> + onProfileUrlChange(selectedPhotos.first().uri) + onPhotoPickerClick() + }, + onPermissionDenied = { onPhotoPickerClick() }, + onBackPressed = { onPhotoPickerClick() }, + isCameraNeeded = false, + ) + } else { + MapisodeScaffold( + modifier = modifier.fillMaxSize(), + isStatusBarPaddingExist = true, + isNavigationBarPaddingExist = true, + topBar = { + ProfileEditTopBar( + onBackClick = onBackClick, + ) + }, + ) { paddingValues -> + Box( + modifier = modifier + .padding(paddingValues) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.85f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(0.5f)) + + MapisodeText( + text = stringResource(R.string.mypage_nickname), + modifier = Modifier.align(Alignment.Start), + style = MapisodeTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + MapisodeTextField( + value = nickname, + onValueChange = { onNicknameChanged(it) }, + placeholder = stringResource(R.string.mypage_placeholder_nickname), + modifier = Modifier + .fillMaxWidth(), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + ) + + Spacer(modifier = Modifier.weight(2f)) + + MapisodeText( + text = stringResource(R.string.mypage_profile_image), + modifier = Modifier.align(Alignment.Start), + style = MapisodeTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + MapisodeImageButton( + onClick = { onPhotoPickerClick() }, + showImage = profileUrl.isBlank(), + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + AsyncImage( + model = profileUrl, + contentDescription = stringResource(R.string.mypage_profile_image), + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + + Spacer(modifier = Modifier.weight(3f)) + + MapisodeFilledButton( + text = stringResource(R.string.mypage_edit), + onClick = onEditClick, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +fun ProfileEditTopBar( + onBackClick: () -> Unit, +) { + TopAppBar( + navigationIcon = { + MapisodeIconButton( + onClick = onBackClick, + ) { + MapisodeIcon(id = drawable.ic_arrow_back_ios) + } + }, + title = stringResource(R.string.mypage_edit_profile), + ) +} + +@Preview( + showBackground = true, + showSystemUi = true, +) +@Composable +fun SignUpScreenPreview() { + MapisodeTheme { + ProfileEditScreen( + isPhotoPickerClicked = false, + onPhotoPickerClick = {}, + nickname = "nickname", + onNicknameChanged = {}, + profileUrl = "", + onProfileUrlChange = {}, + onEditClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/sideeffect/MypageSideEffect.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/sideeffect/MypageSideEffect.kt new file mode 100644 index 00000000..1a8a0884 --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/sideeffect/MypageSideEffect.kt @@ -0,0 +1,11 @@ +package com.boostcamp.mapisode.mypage.sideeffect + +import com.boostcamp.mapisode.ui.base.SideEffect + +sealed interface MypageSideEffect : SideEffect { + data object Idle : MypageSideEffect + data class ShowToast(val messageResId: Int) : MypageSideEffect + data object NavigateToLoginScreen : MypageSideEffect + data object NavigateToEditScreen : MypageSideEffect + data object OpenPrivacyPolicy : MypageSideEffect +} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/sideeffect/ProfileEditSideEffect.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/sideeffect/ProfileEditSideEffect.kt new file mode 100644 index 00000000..d0349356 --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/sideeffect/ProfileEditSideEffect.kt @@ -0,0 +1,8 @@ +package com.boostcamp.mapisode.mypage.sideeffect + +import com.boostcamp.mapisode.ui.base.SideEffect + +sealed interface ProfileEditSideEffect : SideEffect { + data class ShowToast(val messageResId: Int) : ProfileEditSideEffect + data object NavigateToMypage : ProfileEditSideEffect +} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/state/MypageState.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/state/MypageState.kt new file mode 100644 index 00000000..7711f8a0 --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/state/MypageState.kt @@ -0,0 +1,10 @@ +package com.boostcamp.mapisode.mypage.state + +import com.boostcamp.mapisode.ui.base.UiState + +data class MypageState( + val isLoading: Boolean = true, + val name: String = "", + val profileUrl: String = "", + val showWithdrawalDialog: Boolean = false, +) : UiState diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/state/ProfileEditState.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/state/ProfileEditState.kt new file mode 100644 index 00000000..1c6c21be --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/state/ProfileEditState.kt @@ -0,0 +1,11 @@ +package com.boostcamp.mapisode.mypage.state + +import com.boostcamp.mapisode.ui.base.UiState + +data class ProfileEditState( + val isLoading: Boolean = true, + val uid: String = "", + val name: String = "", + val profileUrl: String = "", + val isPhotoPickerClicked: Boolean = false, +) : UiState diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/viewmodel/MypageViewModel.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/viewmodel/MypageViewModel.kt new file mode 100644 index 00000000..df340c65 --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/viewmodel/MypageViewModel.kt @@ -0,0 +1,98 @@ +package com.boostcamp.mapisode.mypage.viewmodel + +import androidx.lifecycle.viewModelScope +import com.boostcamp.mapisode.auth.GoogleOauth +import com.boostcamp.mapisode.datastore.UserPreferenceDataStore +import com.boostcamp.mapisode.mypage.R +import com.boostcamp.mapisode.mypage.intent.MypageIntent +import com.boostcamp.mapisode.mypage.sideeffect.MypageSideEffect +import com.boostcamp.mapisode.mypage.state.MypageState +import com.boostcamp.mapisode.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MypageViewModel @Inject constructor( + private val userPreferenceDataStore: UserPreferenceDataStore, +) : BaseViewModel(MypageState()) { + + override fun onIntent(intent: MypageIntent) { + when (intent) { + is MypageIntent.Init -> initState() + is MypageIntent.LogoutClick -> handleLogoutClick(intent.googleOauth) + is MypageIntent.ProfileEditClick -> handleProfileEditClick() + is MypageIntent.PrivacyPolicyClick -> handlePrivacyPolicyClick() + is MypageIntent.WithdrawalClick -> handleWithdrawalClick() + is MypageIntent.TurnOffDialog -> turnOffDialog() + is MypageIntent.ConfirmClick -> handleConfirmClick(intent.googleOauth) + } + } + + private fun initState() { + try { + viewModelScope.launch { + userPreferenceDataStore.getUserPreferencesFlow().first().let { userPreferences -> + intent { + copy( + isLoading = false, + name = userPreferences.username ?: throw Exception("Username is null"), + profileUrl = userPreferences.profileUrl + ?: throw Exception("ProfileUrl is null"), + ) + } + } + } + } catch (e: Exception) { + postSideEffect(MypageSideEffect.ShowToast(R.string.mypage_error_load_profile)) + } + } + + private fun handleLogoutClick(googleOAuth: GoogleOauth) { + logout(googleOAuth) + postSideEffect(MypageSideEffect.ShowToast(R.string.mypage_logout_success)) + postSideEffect(MypageSideEffect.NavigateToLoginScreen) + } + + private fun logout(googleOAuth: GoogleOauth) { + viewModelScope.launch { + userPreferenceDataStore.clearUserData() + googleOAuth.googleSignOut() + } + } + + private fun handleProfileEditClick() { + postSideEffect(MypageSideEffect.NavigateToEditScreen) + } + + private fun handlePrivacyPolicyClick() { + postSideEffect(MypageSideEffect.OpenPrivacyPolicy) + } + + private fun handleWithdrawalClick() { + intent { + copy(showWithdrawalDialog = true) + } + } + + private suspend fun withdrawal(googleOAuth: GoogleOauth) { + viewModelScope.launch { + googleOAuth.deleteCurrentUser() + userPreferenceDataStore.clearUserData() + }.join() + } + + private fun turnOffDialog() { + intent { + copy(showWithdrawalDialog = false) + } + } + + private fun handleConfirmClick(googleOAuth: GoogleOauth) { + viewModelScope.launch { + withdrawal(googleOAuth) + postSideEffect(MypageSideEffect.NavigateToLoginScreen) + } + } +} diff --git a/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/viewmodel/ProfileEditViewModel.kt b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/viewmodel/ProfileEditViewModel.kt new file mode 100644 index 00000000..69ec3183 --- /dev/null +++ b/feature/mypage/src/main/java/com/boostcamp/mapisode/mypage/viewmodel/ProfileEditViewModel.kt @@ -0,0 +1,110 @@ +package com.boostcamp.mapisode.mypage.viewmodel + +import androidx.lifecycle.viewModelScope +import com.boostcamp.mapisode.datastore.UserPreferenceDataStore +import com.boostcamp.mapisode.mypage.R +import com.boostcamp.mapisode.mypage.intent.ProfileEditIntent +import com.boostcamp.mapisode.mypage.sideeffect.ProfileEditSideEffect +import com.boostcamp.mapisode.mypage.state.ProfileEditState +import com.boostcamp.mapisode.ui.base.BaseViewModel +import com.boostcamp.mapisode.user.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProfileEditViewModel @Inject constructor( + private val userRepository: UserRepository, + private val userPreferenceDataStore: UserPreferenceDataStore, +) : BaseViewModel(ProfileEditState()) { + override fun onIntent(intent: ProfileEditIntent) { + when (intent) { + is ProfileEditIntent.Init -> initState() + is ProfileEditIntent.BackClick -> navigateToMypage() + is ProfileEditIntent.NameChanged -> updateName(intent.nickname) + is ProfileEditIntent.ProfileChanged -> updateProfileUrl(intent.url) + is ProfileEditIntent.PhotopickerClick -> changePhotopickerClicked() + is ProfileEditIntent.EditClick -> editClick() + } + } + + private fun initState() { + try { + viewModelScope.launch { + userPreferenceDataStore.getUserPreferencesFlow().first().let { userPreferences -> + intent { + copy( + isLoading = false, + uid = userPreferences.userId ?: throw Exception("UserId is null"), + name = userPreferences.username ?: throw Exception("Username is null"), + profileUrl = userPreferences.profileUrl + ?: throw Exception("ProfileUrl is null"), + ) + } + } + } + } catch (e: Exception) { + postSideEffect(ProfileEditSideEffect.ShowToast(R.string.mypage_error_load_profile)) + } + } + + private fun updateName(nickname: String) { + intent { + copy( + name = nickname, + ) + } + } + + private fun updateProfileUrl(profileUrl: String) { + intent { + copy( + profileUrl = profileUrl, + ) + } + } + + private fun navigateToMypage() { + postSideEffect(ProfileEditSideEffect.NavigateToMypage) + } + + private fun changePhotopickerClicked() { + intent { + copy( + isPhotoPickerClicked = !isPhotoPickerClicked, + ) + } + } + + private fun editClick() { + try { + viewModelScope.launch { + storeInUserPreferenceDataStore() + storeInUserRepository() + navigateToMypage() + } + } catch (e: Exception) { + postSideEffect(ProfileEditSideEffect.ShowToast(R.string.mypage_error_profile_edit)) + } + } + + private suspend fun storeInUserPreferenceDataStore() { + viewModelScope.launch { + with(userPreferenceDataStore) { + updateUsername(currentState.name) + updateProfileUrl(currentState.profileUrl) + } + }.join() + } + + private suspend fun storeInUserRepository() { + viewModelScope.launch { + userRepository.updateUserNameAndProfileUrl( + uid = currentState.uid, + userName = currentState.name, + profileUrl = currentState.profileUrl, + ) + }.join() + } +} diff --git a/feature/mypage/src/main/res/values/strings.xml b/feature/mypage/src/main/res/values/strings.xml new file mode 100644 index 00000000..464c076e --- /dev/null +++ b/feature/mypage/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + 마이페이지 + 로그아웃 + 프로필 이미지 + 회원탈퇴 + Account + 이용약관 + 닉네임을 입력해주세요. + 닉네임 + 프로필 수정하기 + 수정하기 + 로그아웃 되었습니다. + 정말로 탈퇴하시겠습니까 + 탈퇴하시면 지금까지 쌓아온 에피소드의 복구가 불가능합니다. + 회원탈퇴 + 프로필 수정에 실패했습니다. + 회원정보를 불러올 수 없습니다. + 취소 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ed60373..7cbaaabd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidx-lifecycle-runtime-ktx = "2.8.7" androidx-hilt-navigation-compose = "1.2.0" androidx-datatstore = "1.1.1" androidx-credentials = "1.3.0" +androidx-browser = "1.8.0" # Compose compose-bom = "2024.10.01" @@ -92,6 +93,7 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "androidx-datatstore" } androidx-datatore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datatstore" } androidx-credential = { group = "androidx.credentials", name = "credentials", version.ref = "androidx-credentials" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidx-browser" } # Coil coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }