From 32352c357ba15b57dd341dc275c29652266ae7c5 Mon Sep 17 00:00:00 2001 From: Ju Yungyeom Date: Thu, 11 Sep 2025 23:49:41 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[feature]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A4=EC=A0=95=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../squirtles/core/common/ui/theme/Color.kt | 1 + .../squirtles/core/common/ui/theme/Theme.kt | 6 +- .../core/navigation/UserInfoRoute.kt | 2 +- .../feature/main/navigation/MainNavigator.kt | 3 +- .../userinfo/navigation/UserInfoNavigation.kt | 8 +- .../userinfo/screen/EditProfileScreen.kt | 174 +++++++++++++++++- .../feature/userinfo/screen/UserInfoScreen.kt | 4 +- .../userinfo/src/main/res/values/strings.xml | 3 + 8 files changed, 187 insertions(+), 14 deletions(-) diff --git a/core/common/src/main/java/com/squirtles/core/common/ui/theme/Color.kt b/core/common/src/main/java/com/squirtles/core/common/ui/theme/Color.kt index 86f4a3bd..8bbe8245 100644 --- a/core/common/src/main/java/com/squirtles/core/common/ui/theme/Color.kt +++ b/core/common/src/main/java/com/squirtles/core/common/ui/theme/Color.kt @@ -19,6 +19,7 @@ val Gray = Color(0xFFAAAAAA) val White = Color(0xFFFFFFFF) val PlayerBackground = Color(0xFF353535) +val OptionBackground = Color(0xFF2D2E2E) val SignInButtonDarkBackground = Color(0xFF131314) val SignInButtonLightStroke = Color(0xFF747775) diff --git a/core/common/src/main/java/com/squirtles/core/common/ui/theme/Theme.kt b/core/common/src/main/java/com/squirtles/core/common/ui/theme/Theme.kt index b091b27a..9d7932ff 100644 --- a/core/common/src/main/java/com/squirtles/core/common/ui/theme/Theme.kt +++ b/core/common/src/main/java/com/squirtles/core/common/ui/theme/Theme.kt @@ -16,7 +16,8 @@ private val DarkColorScheme = darkColorScheme( surface = Black, onSurface = White, onSurfaceVariant = DarkGray, - onSecondary = Gray + onSecondary = Gray, + secondaryContainer = OptionBackground ) private val LightColorScheme = lightColorScheme( @@ -29,7 +30,8 @@ private val LightColorScheme = lightColorScheme( surface = White, onSurface = Black, onSurfaceVariant = Gray, - onSecondary = DarkGray + onSecondary = DarkGray, + secondaryContainer = White ) @Composable diff --git a/core/navigation/src/main/java/com/squirtles/core/navigation/UserInfoRoute.kt b/core/navigation/src/main/java/com/squirtles/core/navigation/UserInfoRoute.kt index 6c7d6dbd..7cd76161 100644 --- a/core/navigation/src/main/java/com/squirtles/core/navigation/UserInfoRoute.kt +++ b/core/navigation/src/main/java/com/squirtles/core/navigation/UserInfoRoute.kt @@ -8,7 +8,7 @@ sealed interface UserInfoRoute : Route { data class MyPicks(val uid: String) : UserInfoRoute @Serializable - data class EditProfile(val userName: String) : UserInfoRoute + data class EditProfile(val userName: String, val userProfileImage: String?) : UserInfoRoute @Serializable data object EditNotification : UserInfoRoute diff --git a/feature/main/src/main/java/com/squirtles/feature/main/navigation/MainNavigator.kt b/feature/main/src/main/java/com/squirtles/feature/main/navigation/MainNavigator.kt index b40d65c3..9115e718 100644 --- a/feature/main/src/main/java/com/squirtles/feature/main/navigation/MainNavigator.kt +++ b/feature/main/src/main/java/com/squirtles/feature/main/navigation/MainNavigator.kt @@ -78,9 +78,10 @@ internal class MainNavigator( ) } - fun navigateEditProfile(userName: String) { + fun navigateEditProfile(userName: String, userProfileImage: String?) { navController.navigateEditProfile( userName = userName, + userProfileImage = userProfileImage, navOptions = navOptions { launchSingleTop = true } ) } diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/navigation/UserInfoNavigation.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/navigation/UserInfoNavigation.kt index 6303a1ae..793acae7 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/navigation/UserInfoNavigation.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/navigation/UserInfoNavigation.kt @@ -16,8 +16,8 @@ fun NavController.navigateUserInfo(uid: String, navOptions: NavOptions? = null) navigate(MainRoute.UserInfo(uid), navOptions) } -fun NavController.navigateEditProfile(userName: String, navOptions: NavOptions? = null) { - navigate(UserInfoRoute.EditProfile(userName), navOptions) +fun NavController.navigateEditProfile(userName: String, userProfileImage: String?, navOptions: NavOptions? = null) { + navigate(UserInfoRoute.EditProfile(userName, userProfileImage), navOptions) } fun NavController.navigateEditNotificationSetting(navOptions: NavOptions? = null) { @@ -33,7 +33,7 @@ fun NavGraphBuilder.userInfoNavGraph( onBackToMapClick: () -> Unit, onFavoritePicksClick: (String) -> Unit, onMyPicksClick: (String) -> Unit, - onEditProfileClick: (String) -> Unit, + onEditProfileClick: (String, String?) -> Unit, onEditNotificationClick: () -> Unit, onEditPlayerClick : () -> Unit, ) { @@ -54,8 +54,10 @@ fun NavGraphBuilder.userInfoNavGraph( composable { backStackEntry -> val userName = backStackEntry.toRoute().userName + val userProfileImage = backStackEntry.toRoute().userProfileImage EditProfileScreen( currentUserName = userName, + currentUserProfileImage = userProfileImage, onBackToMapClick = onBackToMapClick, onBackClick = onBackClick, ) diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt index 443a259f..0183c5b9 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt @@ -1,23 +1,35 @@ package com.squirtles.feature.userinfo.screen import android.content.Context +import android.net.Uri import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.Check import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -37,20 +49,26 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.flowWithLifecycle +import coil3.compose.AsyncImage import com.squirtles.core.account.AccountViewModel import com.squirtles.core.account.GoogleId import com.squirtles.core.common.ui.Constants.COLOR_STOPS @@ -58,7 +76,6 @@ import com.squirtles.core.common.ui.DialogTextButton import com.squirtles.core.common.ui.HorizontalSpacer import com.squirtles.core.common.ui.MessageAlertDialog import com.squirtles.core.common.ui.theme.Black -import com.squirtles.core.common.ui.theme.DarkGray import com.squirtles.core.common.ui.theme.Gray import com.squirtles.core.common.ui.theme.MusicRoadTheme import com.squirtles.core.common.ui.theme.Primary @@ -73,6 +90,7 @@ import java.util.regex.Pattern @Composable internal fun EditProfileScreen( currentUserName: String, + currentUserProfileImage: String?, onBackToMapClick: () -> Unit, onBackClick: () -> Unit, userInfoViewModel: UserInfoViewModel = hiltViewModel(), @@ -85,6 +103,7 @@ internal fun EditProfileScreen( val nickNameErrorMessage = remember { mutableStateOf("") } var showLoadingIndicator by rememberSaveable { mutableStateOf(false) } var showDeleteAccountDialog by remember { mutableStateOf(false) } + var selectedImage by remember { mutableStateOf(currentUserProfileImage?.toUri()) } val onDeleteAccountClick: () -> Unit = { GoogleId(context).signOut() @@ -150,7 +169,12 @@ internal fun EditProfileScreen( .padding(innerPadding) ) { // 프로필 수정 - EditProfileContents(userName, nickNameErrorMessage) + EditProfileContents( + userName = userName, + nickNameErrorMessage = nickNameErrorMessage, + profileImage = selectedImage.toString(), + onImageSelected = { uri -> selectedImage = uri } + ) // 회원 탈퇴 Text( @@ -267,7 +291,9 @@ private fun validateUserName(userName: String, context: Context) = when { @Composable private fun EditProfileContents( userName: MutableState, - nickNameErrorMessage: MutableState + nickNameErrorMessage: MutableState, + profileImage: String?, + onImageSelected: (Uri?) -> Unit ) { val context = LocalContext.current Column( @@ -276,6 +302,14 @@ private fun EditProfileContents( .padding(vertical = 30.dp, horizontal = 30.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + ProfileImagePicker( + currentImageUrl = profileImage, + onImageSelected = onImageSelected, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 32.dp) + ) + Text( text = stringResource(id = R.string.setting_profile_nickname), fontSize = 20.sp, @@ -308,6 +342,134 @@ private fun EditProfileContents( } } +@Composable +fun ProfileImagePicker( + currentImageUrl: String?, + onImageSelected: (Uri?) -> Unit, + modifier: Modifier = Modifier +) { + var showProfileMenu by remember { mutableStateOf(false) } + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> onImageSelected(uri) } + + Box( + modifier = modifier.size(180.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .clickable { showProfileMenu = true } + .background(Color.Gray.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + if (currentImageUrl != null) { + AsyncImage( + model = currentImageUrl, + contentDescription = stringResource(R.string.user_info_profile_image), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + placeholder = painterResource(R.drawable.img_user_default_profile), + error = painterResource(R.drawable.img_user_default_profile), + contentScale = ContentScale.Crop + ) + } else { + Icon( + painter = painterResource(R.drawable.img_user_default_profile), + contentDescription = stringResource(R.string.user_info_profile_image), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 8.dp) + .size(50.dp) + .clip(CircleShape) + .clickable { showProfileMenu = true } + .background(Color.White, CircleShape) + .border(2.dp, Color.Gray.copy(alpha = 0.3f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringResource(R.string.setting_profile_image_description), + modifier = Modifier.size(36.dp), + tint = Color.Gray + ) + } + + ProfileOptionsDropdown( + expanded = showProfileMenu, + onDismiss = { showProfileMenu = false }, + onGalleryClick = { + galleryLauncher.launch("image/*") + showProfileMenu = false + }, + onDefaultClick = { + onImageSelected(null) + showProfileMenu = false + } + ) + } +} + +@Composable +fun ProfileOptionsDropdown( + expanded: Boolean, + onDismiss: () -> Unit, + onGalleryClick: () -> Unit, + onDefaultClick: () -> Unit +) { + DropdownMenu( + expanded = expanded, + offset = DpOffset(0.dp, (-20).dp), + onDismissRequest = onDismiss, + shape = RoundedCornerShape(12.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.setting_profile_image_option_album), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + onGalleryClick() + onDismiss() + }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) + ) + + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.setting_profile_image_option_default), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + onDefaultClick() + onDismiss() + }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) + ) + } +} + @Preview @Composable private fun EditProfileAppBarPreview() { @@ -319,8 +481,10 @@ private fun EditProfileAppBarPreview() { private fun EditProfileContentPreview() { MusicRoadTheme { EditProfileContents( - remember { mutableStateOf("짱구") }, - remember { mutableStateOf("") } + userName = remember { mutableStateOf("짱구") }, + nickNameErrorMessage = remember { mutableStateOf("") }, + profileImage = null, + onImageSelected = {} ) } } diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/UserInfoScreen.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/UserInfoScreen.kt index be9c7bbc..01482615 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/UserInfoScreen.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/UserInfoScreen.kt @@ -102,7 +102,7 @@ fun UserInfoScreen( onBackToMapClick: () -> Unit, onFavoritePicksClick: (String) -> Unit, onMyPicksClick: (String) -> Unit, - onEditProfileClick: (String) -> Unit, + onEditProfileClick: (String, String?) -> Unit, onEditNotificationClick: () -> Unit, onEditPlayerClick: () -> Unit, userInfoViewModel: UserInfoViewModel = hiltViewModel(), @@ -148,7 +148,7 @@ fun UserInfoScreen( onBackToMapClick = onBackToMapClick, onFavoritePicksClick = { onFavoritePicksClick(user.uid) }, onMyPicksClick = { onMyPicksClick(user.uid) }, - onEditProfileClick = { onEditProfileClick(user.userName) }, + onEditProfileClick = { onEditProfileClick(user.userName, user.userProfileImage) }, onEditNotificationClick = onEditNotificationClick, onEditPlayerClick = onEditPlayerClick, onLogOutMenuClick = { showLogOutDialog = true }, diff --git a/feature/userinfo/src/main/res/values/strings.xml b/feature/userinfo/src/main/res/values/strings.xml index 8ad4853a..b1cde561 100644 --- a/feature/userinfo/src/main/res/values/strings.xml +++ b/feature/userinfo/src/main/res/values/strings.xml @@ -46,6 +46,9 @@ 변경 사항 적용 변경 사항이 적용되었습니다. 일시적인 오류가 발생했습니다. + 프로필 사진 변경 + 앨범에서 사진 선택 + 기본 이미지 선택 로그아웃 하시겠습니까? From 15a12cdd5d322a17345545a7923ea60a59a60d54 Mon Sep 17 00:00:00 2001 From: Ju Yungyeom Date: Fri, 12 Sep 2025 00:06:00 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[feature]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A7=8C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=8F=84=20=EC=88=98=EC=A0=95=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/squirtles/feature/userinfo/screen/EditProfileScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt index 0183c5b9..a887e7c7 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt @@ -153,7 +153,7 @@ internal fun EditProfileScreen( topBar = { EditProfileAppBar( confirmEnabled = nickNameErrorMessage.value.isEmpty() && - currentUserName != userName.value, + (currentUserName != userName.value || currentUserProfileImage?.toUri() != selectedImage), onConfirmClick = { showLoadingIndicator = true userInfoViewModel.updateUsername(userName.value) From 9e4e0e31a02d90f8b183b0595247b2a18e93bd69 Mon Sep 17 00:00:00 2001 From: Ju Yungyeom Date: Sat, 13 Sep 2025 17:11:12 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[feature]=20=EB=8B=89=EB=84=A4=EC=9E=84?= =?UTF-8?q?=EA=B3=BC=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=83=81=ED=83=9C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/UpdateUserProfileImageUseCase.kt | 12 +++ .../feature/userinfo/UserInfoViewModel.kt | 90 +++++++++++++++++-- .../userinfo/screen/EditProfileScreen.kt | 54 +++++++---- 3 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt diff --git a/domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt b/domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt new file mode 100644 index 00000000..34f643dc --- /dev/null +++ b/domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt @@ -0,0 +1,12 @@ +package com.squirtles.domain.user.usecase + +import com.squirtles.domain.user.FirebaseUserRepository +import javax.inject.Inject + +class UpdateUserProfileImageUseCase @Inject constructor( + private val firebaseUserRepository: FirebaseUserRepository +) { + suspend operator fun invoke(userId: String, newUserProfileImage: String?) { + // TODO 프로필 이미지 업데이트 + } +} diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt index 1670387b..90a5ef1e 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt @@ -1,10 +1,12 @@ package com.squirtles.feature.userinfo +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squirtles.domain.user.usecase.FetchUserByIdUseCase import com.squirtles.domain.user.usecase.GetCurrentUidUseCase import com.squirtles.domain.user.usecase.UpdateUserNameUseCase +import com.squirtles.domain.user.usecase.UpdateUserProfileImageUseCase import com.squirtles.feature.userinfo.UserInfoConstants.DEFAULT_USER import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -14,11 +16,28 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +sealed class UserNameState { + data object Unchanged : UserNameState() + data class New(val userName: String) : UserNameState() +} + +sealed class ProfileImageState { + data object Unchanged : ProfileImageState() + data object Remove : ProfileImageState() + data class New(val userProfileImage: String) : ProfileImageState() +} + +data class UpdateState( + val nameSuccess: Boolean, + val imageSuccess: Boolean +) + @HiltViewModel class UserInfoViewModel @Inject constructor( private val getCurrentUidUseCase: GetCurrentUidUseCase, private val fetchUserByIdUseCase: FetchUserByIdUseCase, - private val updateUserNameUseCase: UpdateUserNameUseCase + private val updateUserNameUseCase: UpdateUserNameUseCase, + private val updateUserProfileImageUseCase: UpdateUserProfileImageUseCase ) : ViewModel() { private val _profileUser = MutableStateFlow(DEFAULT_USER) @@ -26,8 +45,8 @@ class UserInfoViewModel @Inject constructor( val currentUid get() = getCurrentUidUseCase() - private val _updateSuccess = MutableSharedFlow() - val updateSuccess = _updateSuccess.asSharedFlow() + private val _updateState = MutableSharedFlow() + val updateState = _updateState.asSharedFlow() fun getUserById(uid: String) { viewModelScope.launch { @@ -36,16 +55,69 @@ class UserInfoViewModel @Inject constructor( } } - fun updateUsername(newUserName: String) { + fun updateProfile(userNameState: UserNameState, profileImageState: ProfileImageState) { viewModelScope.launch { - currentUid?.let { uid -> - val result = runCatching { - updateUserNameUseCase(uid, newUserName).getOrThrow() - fetchUserByIdUseCase(uid).getOrThrow() + val nameResult = updateUsername(userNameState) + val imageResult = updateUserProfileImage(profileImageState) + + if (!nameResult) Log.e("UserInfoViewModel", "닉네임 변경 실패") + if (!imageResult) Log.e("UserInfoViewModel", "프로필 사진 변경 실패") + + if (nameResult || imageResult) { + currentUid?.let { uid -> + runCatching { + fetchUserByIdUseCase(uid).getOrThrow() + } } - _updateSuccess.emit(result.isSuccess) + } + + _updateState.emit( + UpdateState( + nameSuccess = nameResult, + imageSuccess = imageResult + ) + ) + } + } + + private suspend fun updateUsername(userNameState: UserNameState): Boolean { + val userName = when (userNameState) { + is UserNameState.Unchanged -> { + return true + } + + is UserNameState.New -> { + userNameState.userName + } + } + + return currentUid?.let { uid -> + runCatching { + updateUserNameUseCase(uid, userName).getOrThrow() + }.isSuccess + } ?: false + } + + private suspend fun updateUserProfileImage(profileImageState: ProfileImageState): Boolean { + val userProfileImage = when (profileImageState) { + is ProfileImageState.Unchanged -> { + return true + } + + is ProfileImageState.Remove -> { + null + } + + is ProfileImageState.New -> { + profileImageState.userProfileImage } } + + return currentUid?.let { uid -> + runCatching { + updateUserProfileImageUseCase(uid, userProfileImage) + }.isSuccess + } ?: false } } diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt index a887e7c7..e5885956 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt @@ -80,9 +80,11 @@ import com.squirtles.core.common.ui.theme.Gray import com.squirtles.core.common.ui.theme.MusicRoadTheme import com.squirtles.core.common.ui.theme.Primary import com.squirtles.core.common.ui.theme.White +import com.squirtles.feature.userinfo.ProfileImageState import com.squirtles.feature.userinfo.R import com.squirtles.feature.userinfo.UserInfoConstants.USERNAME_PATTERN import com.squirtles.feature.userinfo.UserInfoViewModel +import com.squirtles.feature.userinfo.UserNameState import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.regex.Pattern @@ -114,25 +116,30 @@ internal fun EditProfileScreen( LaunchedEffect(Unit) { launch { - userInfoViewModel.updateSuccess + userInfoViewModel.updateState .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) - .collect { isSuccess -> + .collect { updateState -> focusManager.clearFocus() delay(100) - if (isSuccess) { - Toast.makeText( - context, - context.getString(R.string.setting_profile_update_nickname_success), - Toast.LENGTH_SHORT - ).show() - onBackClick() - } else { - showLoadingIndicator = false - Toast.makeText( - context, - context.getString(R.string.setting_profile_update_nickname_failure), - Toast.LENGTH_SHORT - ).show() + + when { + updateState.nameSuccess && updateState.imageSuccess -> { + Toast.makeText( + context, + context.getString(R.string.setting_profile_update_nickname_success), + Toast.LENGTH_SHORT + ).show() + onBackClick() + } + + else -> { + showLoadingIndicator = false + Toast.makeText( + context, + context.getString(R.string.setting_profile_update_nickname_failure), + Toast.LENGTH_SHORT + ).show() + } } } } @@ -156,7 +163,20 @@ internal fun EditProfileScreen( (currentUserName != userName.value || currentUserProfileImage?.toUri() != selectedImage), onConfirmClick = { showLoadingIndicator = true - userInfoViewModel.updateUsername(userName.value) + userInfoViewModel.updateProfile( + userNameState = if (currentUserName == userName.value) { + UserNameState.Unchanged + } else { + UserNameState.New(userName.value) + }, + profileImageState = if (currentUserProfileImage?.toUri() == selectedImage) { + ProfileImageState.Unchanged + } else if (selectedImage == null) { + ProfileImageState.Remove + } else { + ProfileImageState.New(selectedImage.toString()) + } + ) }, onBackClick = onBackClick ) From b844fd36e012715119aa5596ac86452f9eb743a5 Mon Sep 17 00:00:00 2001 From: Ju Yungyeom Date: Sun, 14 Sep 2025 19:42:09 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[feature]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/buildconfig/build.gradle.kts | 12 ++++ .../core/buildconfig/LocalPropertyProvider.kt | 2 + data/firebase/build.gradle.kts | 2 + .../firebase/FirebaseDataSourceConstants.kt | 1 + .../squirtles/data/firebase/FirebaseModule.kt | 7 +++ data/user/build.gradle.kts | 2 + .../data/user/FirebaseUserDataSource.kt | 2 + .../data/user/FirebaseUserDataSourceImpl.kt | 58 ++++++++++++++++++- .../data/user/FirebaseUserRepositoryImpl.kt | 8 +++ .../squirtles/data/user/di/UserDiModule.kt | 5 +- .../domain/user/FirebaseUserRepository.kt | 3 + .../usecase/DeleteUserProfileImageUseCase.kt | 12 ++++ .../usecase/UpdateUserProfileImageUseCase.kt | 4 +- .../feature/userinfo/UserInfoViewModel.kt | 42 ++++++++++---- .../userinfo/screen/EditProfileScreen.kt | 2 +- gradle/libs.versions.toml | 2 + 16 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 domain/user/src/main/java/com/squirtles/domain/user/usecase/DeleteUserProfileImageUseCase.kt diff --git a/core/buildconfig/build.gradle.kts b/core/buildconfig/build.gradle.kts index 0402a087..7b1dd4b5 100644 --- a/core/buildconfig/build.gradle.kts +++ b/core/buildconfig/build.gradle.kts @@ -40,6 +40,12 @@ android { "\"${properties.getProperty("FIRESTORE_DB_ID_DEBUG")}\"" ) + buildConfigField( + "String", + "FIRESTORE_STORAGE_ID", + "\"${properties.getProperty("FIRESTORE_STORAGE_ID_DEBUG")}\"" + ) + buildConfigField( "String", "HTTPS_CALLABLE", @@ -56,6 +62,12 @@ android { "\"${properties.getProperty("FIRESTORE_DB_ID_RELEASE")}\"" ) + buildConfigField( + "String", + "FIRESTORE_STORAGE_ID", + "\"${properties.getProperty("FIRESTORE_STORAGE_ID_RELEASE")}\"" + ) + buildConfigField( "String", "HTTPS_CALLABLE", diff --git a/core/buildconfig/src/main/java/com/squirtles/core/buildconfig/LocalPropertyProvider.kt b/core/buildconfig/src/main/java/com/squirtles/core/buildconfig/LocalPropertyProvider.kt index 51000c78..c894a10f 100644 --- a/core/buildconfig/src/main/java/com/squirtles/core/buildconfig/LocalPropertyProvider.kt +++ b/core/buildconfig/src/main/java/com/squirtles/core/buildconfig/LocalPropertyProvider.kt @@ -7,5 +7,7 @@ object LocalPropertyProvider { const val firestoreDbId: String = BuildConfig.FIRESTORE_DB_ID + const val firebaseStorageId: String = BuildConfig.FIRESTORE_STORAGE_ID + const val httpsCallable: String = BuildConfig.HTTPS_CALLABLE } diff --git a/data/firebase/build.gradle.kts b/data/firebase/build.gradle.kts index 3b61ea8f..8921b864 100644 --- a/data/firebase/build.gradle.kts +++ b/data/firebase/build.gradle.kts @@ -13,6 +13,8 @@ dependencies { androidTestImplementation(libs.bundles.test) // Firebase + implementation(platform(libs.firebase.bom)) implementation(libs.firebase.firestore.ktx) + implementation(libs.firebase.storage) implementation(libs.geofire.android.common) } diff --git a/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseDataSourceConstants.kt b/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseDataSourceConstants.kt index f827cd61..9966e487 100644 --- a/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseDataSourceConstants.kt +++ b/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseDataSourceConstants.kt @@ -12,6 +12,7 @@ sealed class FirebaseDocumentFields(val name: String) { data object Uid: FirebaseDocumentFields("uid") data object MyPicks: FirebaseDocumentFields("myPicks") data object Name: FirebaseDocumentFields("name") + data object ProfileImage: FirebaseDocumentFields("profileImage") data object Location: FirebaseDocumentFields("location") data object GeoHash: FirebaseDocumentFields("geoHash") data object CreatedUserName: FirebaseDocumentFields("createdBy.userName") diff --git a/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseModule.kt b/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseModule.kt index 7a466c5e..685c4d38 100644 --- a/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseModule.kt +++ b/data/firebase/src/main/java/com/squirtles/data/firebase/FirebaseModule.kt @@ -1,6 +1,7 @@ package com.squirtles.data.firebase import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage import com.squirtles.core.buildconfig.LocalPropertyProvider import dagger.Module import dagger.Provides @@ -17,4 +18,10 @@ object FirebaseModule { fun provideFirebaseFirestore(): FirebaseFirestore { return FirebaseFirestore.getInstance(LocalPropertyProvider.firestoreDbId) } + + @Provides + @Singleton + fun provideFirebaseStorage(): FirebaseStorage { + return FirebaseStorage.getInstance(LocalPropertyProvider.firebaseStorageId) + } } diff --git a/data/user/build.gradle.kts b/data/user/build.gradle.kts index 03b82a06..22c5421f 100644 --- a/data/user/build.gradle.kts +++ b/data/user/build.gradle.kts @@ -17,6 +17,8 @@ dependencies { implementation(libs.androidx.datastore.preferences) // firebase + implementation(platform(libs.firebase.bom)) implementation(libs.firebase.firestore.ktx) implementation(libs.firebase.auth.ktx) + implementation(libs.firebase.storage) } diff --git a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSource.kt b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSource.kt index 38a43967..2b577685 100644 --- a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSource.kt +++ b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSource.kt @@ -7,4 +7,6 @@ interface FirebaseUserDataSource { suspend fun createGoogleIdUser(uid: String, newUser: FirebaseUser): Result suspend fun updateUserName(uid: String, newUserName: String): Result suspend fun deleteUser(uid: String): Result + suspend fun updateUserProfileImage(uid: String, imageData: ByteArray): Result + suspend fun deleteUserProfileImage(uid: String): Result } diff --git a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt index 3857798c..ea74f82a 100644 --- a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt +++ b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt @@ -4,6 +4,7 @@ import android.util.Log import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.toObject +import com.google.firebase.storage.FirebaseStorage import com.squirtles.data.firebase.BaseFirebaseDataSource import com.squirtles.data.firebase.FirebaseCollections import com.squirtles.data.firebase.FirebaseDocumentFields @@ -14,7 +15,8 @@ import javax.inject.Singleton @Singleton class FirebaseUserDataSourceImpl @Inject constructor( - private val db: FirebaseFirestore + private val db: FirebaseFirestore, + private val storage: FirebaseStorage ) : BaseFirebaseDataSource(db), FirebaseUserDataSource { override suspend fun createGoogleIdUser(uid: String, newUser: FirebaseUser): Result { @@ -63,6 +65,60 @@ class FirebaseUserDataSourceImpl @Inject constructor( } } + private suspend fun deleteImageFromStorage(imageUrl: String) { + try { + val storageRef = storage.getReferenceFromUrl(imageUrl) + storageRef.delete().await() + } catch (e: Exception) { + Log.w(TAG_LOG, "No image found in storage for url: $imageUrl", e) + } + } + + override suspend fun updateUserProfileImage(uid: String, imageData: ByteArray): Result { + return runCatching { + val fileName = "profile_images/${uid}_${System.currentTimeMillis()}.jpg" + val storageRef = storage.reference.child(fileName) + + storageRef.putBytes(imageData).await() + val downloadUrl = storageRef.downloadUrl.await().toString() + val userDocRef = db.collection(FirebaseCollections.Users.name).document(uid) + + try { + var oldImageUrl: String? = null + db.runTransaction { transaction -> + val snapshot = transaction.get(userDocRef) + oldImageUrl = snapshot.getString(FirebaseDocumentFields.ProfileImage.name) + transaction.update(userDocRef, FirebaseDocumentFields.ProfileImage.name, downloadUrl) + }.await() + + oldImageUrl?.let { url -> deleteImageFromStorage(url) } + } catch (dbError: Exception) { + storageRef.delete().await() + throw dbError + } + + true + }.onFailure { e -> + Log.e(TAG_LOG, "Failed to update user profile image", e) + } + } + + override suspend fun deleteUserProfileImage(uid: String): Result { + return runCatching { + val userDocSnap = fetchDocumentSnapshot(FirebaseCollections.Users, uid).getOrThrow() + val currentProfileImageUrl = userDocSnap.getString(FirebaseDocumentFields.ProfileImage.name) + + currentProfileImageUrl?.let { deleteImageFromStorage(it) } + db.runTransaction { transaction -> + transaction.update(userDocSnap.reference, FirebaseDocumentFields.ProfileImage.name, null) + } + + true + }.onFailure { e -> + Log.e(TAG_LOG, "Failed to delete user profile image", e) + } + } + companion object { const val TAG_LOG = "FirebaseUserDataSourceImpl" } diff --git a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserRepositoryImpl.kt b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserRepositoryImpl.kt index 99ed2bf9..9334c0ea 100644 --- a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserRepositoryImpl.kt +++ b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserRepositoryImpl.kt @@ -44,4 +44,12 @@ class FirebaseUserRepositoryImpl @Inject constructor( override suspend fun deleteUser(uid: String): Result { return userDataSource.deleteUser(uid) } + + override suspend fun updateUserProfileImage(uid: String, imageData: ByteArray): Result { + return userDataSource.updateUserProfileImage(uid, imageData) + } + + override suspend fun deleteUserProfileImage(uid: String): Result { + return userDataSource.deleteUserProfileImage(uid) + } } diff --git a/data/user/src/main/java/com/squirtles/data/user/di/UserDiModule.kt b/data/user/src/main/java/com/squirtles/data/user/di/UserDiModule.kt index f6a8b4e8..e6e4c8f2 100644 --- a/data/user/src/main/java/com/squirtles/data/user/di/UserDiModule.kt +++ b/data/user/src/main/java/com/squirtles/data/user/di/UserDiModule.kt @@ -2,6 +2,7 @@ package com.squirtles.data.user.di import android.content.Context import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage import com.squirtles.data.user.FirebaseUserDataSource import com.squirtles.data.user.FirebaseUserDataSourceImpl import com.squirtles.domain.user.FirebaseUserRepository @@ -37,6 +38,6 @@ object UserDiModule { @Provides @Singleton - fun provideFirebaseUserDataSource(db: FirebaseFirestore): FirebaseUserDataSource = - FirebaseUserDataSourceImpl(db) + fun provideFirebaseUserDataSource(db: FirebaseFirestore, storage: FirebaseStorage): FirebaseUserDataSource = + FirebaseUserDataSourceImpl(db, storage) } diff --git a/domain/user/src/main/java/com/squirtles/domain/user/FirebaseUserRepository.kt b/domain/user/src/main/java/com/squirtles/domain/user/FirebaseUserRepository.kt index 874dc995..d9226ec2 100644 --- a/domain/user/src/main/java/com/squirtles/domain/user/FirebaseUserRepository.kt +++ b/domain/user/src/main/java/com/squirtles/domain/user/FirebaseUserRepository.kt @@ -4,10 +4,13 @@ import com.squirtles.core.model.User interface FirebaseUserRepository { val currentUser: String? + // user fun signOut() suspend fun createGoogleIdUser(uid: String, email: String, userName: String?, userProfileImage: String?): Result suspend fun fetchUser(userId: String): Result suspend fun updateUserName(userId: String, newUserName: String): Result suspend fun deleteUser(uid: String): Result + suspend fun updateUserProfileImage(uid: String, imageData: ByteArray): Result + suspend fun deleteUserProfileImage(uid: String): Result } diff --git a/domain/user/src/main/java/com/squirtles/domain/user/usecase/DeleteUserProfileImageUseCase.kt b/domain/user/src/main/java/com/squirtles/domain/user/usecase/DeleteUserProfileImageUseCase.kt new file mode 100644 index 00000000..2bf1ba4c --- /dev/null +++ b/domain/user/src/main/java/com/squirtles/domain/user/usecase/DeleteUserProfileImageUseCase.kt @@ -0,0 +1,12 @@ +package com.squirtles.domain.user.usecase + +import com.squirtles.domain.user.FirebaseUserRepository +import javax.inject.Inject + +class DeleteUserProfileImageUseCase @Inject constructor( + private val firebaseUserRepository: FirebaseUserRepository +) { + suspend operator fun invoke(userId: String) { + firebaseUserRepository.deleteUserProfileImage(userId) + } +} diff --git a/domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt b/domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt index 34f643dc..bb4fa37d 100644 --- a/domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt +++ b/domain/user/src/main/java/com/squirtles/domain/user/usecase/UpdateUserProfileImageUseCase.kt @@ -6,7 +6,7 @@ import javax.inject.Inject class UpdateUserProfileImageUseCase @Inject constructor( private val firebaseUserRepository: FirebaseUserRepository ) { - suspend operator fun invoke(userId: String, newUserProfileImage: String?) { - // TODO 프로필 이미지 업데이트 + suspend operator fun invoke(userId: String, newImageData: ByteArray) { + firebaseUserRepository.updateUserProfileImage(userId, newImageData) } } diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt index 90a5ef1e..0d4e013f 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt @@ -1,14 +1,18 @@ package com.squirtles.feature.userinfo +import android.content.Context +import android.net.Uri import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squirtles.domain.user.usecase.DeleteUserProfileImageUseCase import com.squirtles.domain.user.usecase.FetchUserByIdUseCase import com.squirtles.domain.user.usecase.GetCurrentUidUseCase import com.squirtles.domain.user.usecase.UpdateUserNameUseCase import com.squirtles.domain.user.usecase.UpdateUserProfileImageUseCase import com.squirtles.feature.userinfo.UserInfoConstants.DEFAULT_USER import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -24,7 +28,7 @@ sealed class UserNameState { sealed class ProfileImageState { data object Unchanged : ProfileImageState() data object Remove : ProfileImageState() - data class New(val userProfileImage: String) : ProfileImageState() + data class New(val userProfileImage: Uri) : ProfileImageState() } data class UpdateState( @@ -34,10 +38,12 @@ data class UpdateState( @HiltViewModel class UserInfoViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val getCurrentUidUseCase: GetCurrentUidUseCase, private val fetchUserByIdUseCase: FetchUserByIdUseCase, private val updateUserNameUseCase: UpdateUserNameUseCase, - private val updateUserProfileImageUseCase: UpdateUserProfileImageUseCase + private val updateUserProfileImageUseCase: UpdateUserProfileImageUseCase, + private val deleteUserProfileImageUseCase: DeleteUserProfileImageUseCase ) : ViewModel() { private val _profileUser = MutableStateFlow(DEFAULT_USER) @@ -99,25 +105,39 @@ class UserInfoViewModel @Inject constructor( } private suspend fun updateUserProfileImage(profileImageState: ProfileImageState): Boolean { - val userProfileImage = when (profileImageState) { + return when (profileImageState) { is ProfileImageState.Unchanged -> { - return true + true } is ProfileImageState.Remove -> { - null + currentUid?.let { uid -> + runCatching { + deleteUserProfileImageUseCase(uid) + }.isSuccess + } ?: false } is ProfileImageState.New -> { - profileImageState.userProfileImage + val newImageData: ByteArray = profileImageState.userProfileImage.toByteArray(context) ?: return false + currentUid?.let { uid -> + runCatching { + updateUserProfileImageUseCase(uid, newImageData) + }.isSuccess + } ?: false } } + } - return currentUid?.let { uid -> - runCatching { - updateUserProfileImageUseCase(uid, userProfileImage) - }.isSuccess - } ?: false + private fun Uri.toByteArray(context: Context): ByteArray? { + return try { + context.contentResolver.openInputStream(this)?.use { inputStream -> + inputStream.readBytes() + } + } catch (e: Exception) { + e.printStackTrace() + null + } } } diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt index e5885956..34cd88a9 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt @@ -174,7 +174,7 @@ internal fun EditProfileScreen( } else if (selectedImage == null) { ProfileImageState.Remove } else { - ProfileImageState.New(selectedImage.toString()) + ProfileImageState.New(selectedImage!!) } ) }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de3b7466..8ba82863 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,6 +101,7 @@ composeMaterialIconsExtended = "1.7.8" animationCoreAndroid = "1.7.8" foundationLayoutAndroid = "1.7.8" foundationAndroid = "1.7.8" +firebaseStorageKtx = "22.0.0" [libraries] # AndroidX @@ -167,6 +168,7 @@ firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-fires firebase-functions-ktx = { group = "com.google.firebase", name = "firebase-functions-ktx", version.ref = "firebaseFunctionsKtx" } firebase-auth-ktx = { group = "com.google.firebase", name = "firebase-auth-ktx", version.ref = "firebaseAuthKtx" } google-firebase-dynamic-module-support = { module = "com.google.firebase:firebase-dynamic-module-support", version.ref = "firebaseDynamicModuleSupportVersion" } +firebase-storage = { module = "com.google.firebase:firebase-storage" } # goefire geofire-android-common = { module = "com.firebase:geofire-android-common", version.ref = "geofireAndroidCommon" } From 61a73cb4680fa51e2655dd309e8837aa9aa2286e Mon Sep 17 00:00:00 2001 From: Ju Yungyeom Date: Sun, 14 Sep 2025 19:52:33 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[feature]=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../squirtles/data/user/FirebaseUserDataSourceImpl.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt index ea74f82a..bbfca1b0 100644 --- a/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt +++ b/data/user/src/main/java/com/squirtles/data/user/FirebaseUserDataSourceImpl.kt @@ -58,6 +58,15 @@ class FirebaseUserDataSourceImpl @Inject constructor( override suspend fun deleteUser(uid: String): Result { return runCatching { + val userDocSnap = fetchDocumentSnapshot(FirebaseCollections.Users, uid).getOrThrow() + val profileImageUrl = userDocSnap.getString(FirebaseDocumentFields.ProfileImage.name) + profileImageUrl?.let { url -> + deleteImageFromStorage(url).runCatching { + }.onFailure { e -> + Log.w(TAG_LOG, "Failed to delete profile image from Storage for uid: $uid", e) + } + } + FirebaseAuth.getInstance().currentUser?.delete()?.await() return deleteDocument(FirebaseCollections.Users, uid) }.onFailure { From 98c3c7d48f7e4674e91d13092fe33b81a81b2de8 Mon Sep 17 00:00:00 2001 From: Ju Yungyeom Date: Sun, 14 Sep 2025 21:05:20 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[feature]=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=ED=95=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=95=95=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/userinfo/UserInfoViewModel.kt | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt index 0d4e013f..1028d786 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/UserInfoViewModel.kt @@ -1,8 +1,11 @@ package com.squirtles.feature.userinfo import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.util.Log +import androidx.core.graphics.scale import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squirtles.domain.user.usecase.DeleteUserProfileImageUseCase @@ -18,6 +21,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream import javax.inject.Inject sealed class UserNameState { @@ -129,10 +133,43 @@ class UserInfoViewModel @Inject constructor( } } - private fun Uri.toByteArray(context: Context): ByteArray? { + private fun Uri.toByteArray(context: Context, maxSizeDp: Int = 180): ByteArray? { return try { + val density = context.resources.displayMetrics.density + val maxSizePx = (maxSizeDp * density).toInt() + + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } context.contentResolver.openInputStream(this)?.use { inputStream -> - inputStream.readBytes() + BitmapFactory.decodeStream(inputStream, null, options) + } + + if (options.outWidth <= 0 || options.outHeight <= 0) { + return null + } + + val maxDimension = maxOf(options.outWidth, options.outHeight) + var sampleSize = 1 + if (maxDimension > maxSizePx) { + val half = maxDimension / 2 + while (half / sampleSize > maxSizePx) { + sampleSize *= 2 + } + } + val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } + val bitmap = context.contentResolver.openInputStream(this)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeOptions) + } ?: return null + + val scale = maxSizePx.toFloat() / maxOf(bitmap.width, bitmap.height) + val resizedBitmap = if (scale < 1f) { + bitmap.scale((bitmap.width * scale).toInt(), (bitmap.height * scale).toInt()) + } else bitmap + + ByteArrayOutputStream().use { baos -> + resizedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos) + if (resizedBitmap != bitmap) bitmap.recycle() + resizedBitmap.recycle() + baos.toByteArray() } } catch (e: Exception) { e.printStackTrace() From f56fba5c312580fdca7c3f957cd6e621b4290408 Mon Sep 17 00:00:00 2001 From: Ju Yungyeom Date: Sun, 14 Sep 2025 23:25:48 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[feature]=20=ED=99=94=EB=A9=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=A0=84=EC=97=90=20=EB=8C=80=EC=9D=91=ED=95=98=EB=A9=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userinfo/screen/EditProfileScreen.kt | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt index 34cd88a9..72056c85 100644 --- a/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt +++ b/feature/userinfo/src/main/java/com/squirtles/feature/userinfo/screen/EditProfileScreen.kt @@ -14,14 +14,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CameraAlt @@ -101,11 +104,12 @@ internal fun EditProfileScreen( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val focusManager = LocalFocusManager.current - val userName = remember { mutableStateOf(currentUserName) } - val nickNameErrorMessage = remember { mutableStateOf("") } + val userName = rememberSaveable { mutableStateOf(currentUserName) } + val nickNameErrorMessage = rememberSaveable { mutableStateOf("") } + var selectedImage by rememberSaveable { mutableStateOf(currentUserProfileImage?.toUri()) } + var showLoadingIndicator by rememberSaveable { mutableStateOf(false) } var showDeleteAccountDialog by remember { mutableStateOf(false) } - var selectedImage by remember { mutableStateOf(currentUserProfileImage?.toUri()) } val onDeleteAccountClick: () -> Unit = { GoogleId(context).signOut() @@ -188,25 +192,33 @@ internal fun EditProfileScreen( .background(Brush.verticalGradient(colorStops = COLOR_STOPS)) .padding(innerPadding) ) { - // 프로필 수정 - EditProfileContents( - userName = userName, - nickNameErrorMessage = nickNameErrorMessage, - profileImage = selectedImage.toString(), - onImageSelected = { uri -> selectedImage = uri } - ) - - // 회원 탈퇴 - Text( - text = stringResource(id = R.string.user_info_setting_delete_user_account), + Column( modifier = Modifier - .padding(vertical = 20.dp) - .clickable { showDeleteAccountDialog = true } - .align(Alignment.BottomCenter), - color = Gray, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium - ) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 프로필 수정 + EditProfileContents( + userName = userName, + nickNameErrorMessage = nickNameErrorMessage, + profileImage = selectedImage.toString(), + onImageSelected = { uri -> selectedImage = uri } + ) + + Spacer(modifier = Modifier.weight(1f)) + + // 회원 탈퇴 + Text( + text = stringResource(id = R.string.user_info_setting_delete_user_account), + modifier = Modifier + .padding(vertical = 20.dp) + .clickable { showDeleteAccountDialog = true }, + color = Gray, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } } }