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" }