From c097bc61468953eaec37f0a24d3d428558f4c8c1 Mon Sep 17 00:00:00 2001 From: kimjw Date: Wed, 20 May 2026 22:58:24 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=83=88=ED=87=B4=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditProfileScreen: 프로필 이미지 변경, 닉네임 중복 체크 후 수정 완료 - WithdrawScreen: 유의사항 확인 체크박스, Destructive 버튼 상태로 탈퇴 처리 - setting 패키지를 setting/editprofile/withdraw 로 분리하여 구조 정리 - FlintButtonState에 Destructive 상태(error500) 추가 - Route에 EditProfile, Withdraw 추가 및 네비게이션 연결 Co-Authored-By: Claude Sonnet 4.6 --- .../component/button/FlintButtonState.kt | 17 + .../java/com/flint/core/navigation/Route.kt | 9 + .../flint/presentation/main/MainNavHost.kt | 20 ++ .../flint/presentation/main/MainNavigator.kt | 15 + .../presentation/setting/SettingScreen.kt | 299 ++++++++++++++++++ .../presentation/setting/SettingUiState.kt | 9 + .../presentation/setting/SettingViewModel.kt | 82 +++++ .../setting/editprofile/EditProfileScreen.kt | 264 ++++++++++++++++ .../setting/editprofile/EditProfileUiState.kt | 42 +++ .../editprofile/EditProfileViewModel.kt | 101 ++++++ .../navigation/EditProfileNavigation.kt | 22 ++ .../setting/navigation/SettingNavigation.kt | 28 ++ .../setting/withdraw/WithdrawScreen.kt | 195 ++++++++++++ .../setting/withdraw/WithdrawViewModel.kt | 50 +++ .../withdraw/navigation/WithdrawNavigation.kt | 24 ++ 15 files changed, 1177 insertions(+) create mode 100644 app/src/main/java/com/flint/presentation/setting/SettingScreen.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/SettingUiState.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/editprofile/navigation/EditProfileNavigation.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/navigation/SettingNavigation.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt diff --git a/app/src/main/java/com/flint/core/designsystem/component/button/FlintButtonState.kt b/app/src/main/java/com/flint/core/designsystem/component/button/FlintButtonState.kt index 7f8b130a..641e2302 100644 --- a/app/src/main/java/com/flint/core/designsystem/component/button/FlintButtonState.kt +++ b/app/src/main/java/com/flint/core/designsystem/component/button/FlintButtonState.kt @@ -77,6 +77,23 @@ enum class FlintButtonState() { @ReadOnlyComposable get() = BorderStroke(2.dp, FlintTheme.colors.primary400) }, + + Destructive { + override val background: Brush + @Composable + @ReadOnlyComposable + get() = SolidColor(FlintTheme.colors.error500) + + override val contentColor: Color + @Composable + @ReadOnlyComposable + get() = FlintTheme.colors.white + + override val border: BorderStroke? + @Composable + @ReadOnlyComposable + get() = null + }, ; abstract val background: Brush diff --git a/app/src/main/java/com/flint/core/navigation/Route.kt b/app/src/main/java/com/flint/core/navigation/Route.kt index 0eebba20..c8c23866 100644 --- a/app/src/main/java/com/flint/core/navigation/Route.kt +++ b/app/src/main/java/com/flint/core/navigation/Route.kt @@ -60,4 +60,13 @@ interface Route { data class Profile( val userId: String? = null, ) : Route + + @Serializable + data object Setting : Route + + @Serializable + data object EditProfile : Route + + @Serializable + data object Withdraw : Route } diff --git a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt index 84a0dfb3..33949a04 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt @@ -18,6 +18,9 @@ import com.flint.presentation.onboarding.navigation.onBoardingNavGraph import com.flint.presentation.profile.navigation.myProfileNavGraph import com.flint.presentation.profile.navigation.profileNavGraph import com.flint.presentation.savedcontent.navigation.savedContentListNavGraph +import com.flint.presentation.setting.editprofile.navigation.editProfileNavGraph +import com.flint.presentation.setting.navigation.settingNavGraph +import com.flint.presentation.setting.withdraw.navigation.withdrawNavGraph import com.flint.presentation.splash.navigation.splashNavGraph @Composable @@ -96,6 +99,7 @@ fun MainNavHost( navigateToCollectionList = navigator::navigateToCollectionList, navigateToSavedContentList = navigator::navigateToSavedContent, navigateToCollectionDetail = navigator::navigateToCollectionDetail, + navigateToSetting = navigator::navigateToSetting, ) profileNavGraph( @@ -105,6 +109,22 @@ fun MainNavHost( navigateToSavedContentList = navigator::navigateToSavedContent, navigateToCollectionDetail = navigator::navigateToCollectionDetail, ) + + settingNavGraph( + navigateUp = navigator::navigateUp, + navigateToLogin = navigator::navigateToLogin, + navigateToEditProfile = navigator::navigateToEditProfile, + navigateToWithdraw = navigator::navigateToWithdraw, + ) + + editProfileNavGraph( + navigateUp = navigator::navigateUp, + ) + + withdrawNavGraph( + navigateUp = navigator::navigateUp, + navigateToLogin = navigator::navigateToLogin, + ) } } } diff --git a/app/src/main/java/com/flint/presentation/main/MainNavigator.kt b/app/src/main/java/com/flint/presentation/main/MainNavigator.kt index 57d6cf88..71bc623c 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavigator.kt @@ -22,6 +22,9 @@ import com.flint.presentation.onboarding.navigation.navigateToOnboarding import com.flint.presentation.profile.navigation.navigateToMyProfile import com.flint.presentation.profile.navigation.navigateToProfile import com.flint.presentation.savedcontent.navigation.navigateToSavedContentList +import com.flint.presentation.setting.editprofile.navigation.navigateToEditProfile +import com.flint.presentation.setting.navigation.navigateToSetting +import com.flint.presentation.setting.withdraw.navigation.navigateToWithdraw import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -155,6 +158,18 @@ class MainNavigator( navController.navigateToProfile(userId, navOptions) } + fun navigateToSetting(navOptions: NavOptions? = null) { + navController.navigateToSetting(navOptions) + } + + fun navigateToEditProfile(navOptions: NavOptions? = null) { + navController.navigateToEditProfile(navOptions) + } + + fun navigateToWithdraw(navOptions: NavOptions? = null) { + navController.navigateToWithdraw(navOptions) + } + fun navigateUp() { navController.navigateUp() } diff --git a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt new file mode 100644 index 00000000..f0b55ce3 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt @@ -0,0 +1,299 @@ +package com.flint.presentation.setting + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +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 com.flint.R +import com.flint.core.common.extension.noRippleClickable +import com.flint.core.designsystem.component.button.FlintButtonState +import com.flint.core.designsystem.component.button.FlintMediumButton +import com.flint.core.designsystem.component.image.ProfileImage +import com.flint.core.designsystem.component.topappbar.FlintBackTopAppbar +import com.flint.core.designsystem.theme.FlintTheme + +@Composable +fun SettingRoute( + navigateUp: () -> Unit, + navigateToLogin: () -> Unit, + navigateToEditProfile: () -> Unit, + navigateToWithdraw: () -> Unit, + viewModel: SettingViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadUserInfo() + } + + LaunchedEffect(Unit) { + viewModel.navigateToLogin.collect { + navigateToLogin() + } + } + + SettingScreen( + uiState = uiState, + onBackClick = navigateUp, + onEditProfileClick = navigateToEditProfile, + onLogoutClick = viewModel::showLogoutDialog, + onWithdrawClick = navigateToWithdraw, + onLogoutConfirm = { + viewModel.dismissLogoutDialog() + viewModel.logout() + }, + onLogoutDismiss = viewModel::dismissLogoutDialog, + ) +} + +@Composable +private fun SettingScreen( + uiState: SettingUiState, + onBackClick: () -> Unit, + onEditProfileClick: () -> Unit, + onLogoutClick: () -> Unit, + onWithdrawClick: () -> Unit, + onLogoutConfirm: () -> Unit, + onLogoutDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(FlintTheme.colors.background), + ) { + Column(modifier = Modifier.fillMaxSize()) { + FlintBackTopAppbar( + onClick = onBackClick, + title = "설정", + ) + + SettingProfileSection( + nickname = uiState.nickname, + profileImageUrl = uiState.profileImageUrl, + onEditProfileClick = onEditProfileClick, + modifier = Modifier.fillMaxWidth(), + ) + + HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 12.dp) + + SettingMenuItem( + label = "계정", + trailingContent = { + if (uiState.email.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = uiState.email, + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.white, + ) + Spacer(Modifier.width(4.dp)) + Image( + painter = painterResource(R.drawable.ic_kakao_full), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + }, + ) + + HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) + + SettingMenuItem(label = "개인정보 정책") + + HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) + + SettingMenuItem(label = "이용약관") + + HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) + + SettingMenuItem( + label = "로그아웃", + onClick = onLogoutClick, + ) + + HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) + + Spacer(Modifier.weight(1f)) + + Text( + text = "탈퇴하기", + style = FlintTheme.typography.body2M14, + color = FlintTheme.colors.gray300, + textDecoration = TextDecoration.Underline, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .noRippleClickable(onClick = onWithdrawClick) + .padding(vertical = 16.dp), + ) + } + } + + if (uiState.isLogoutDialogVisible) { + SettingConfirmDialog( + title = "로그아웃", + message = "로그아웃 하시겠어요?", + confirmText = "로그아웃", + onConfirm = onLogoutConfirm, + onDismiss = onLogoutDismiss, + ) + } + +} + +@Composable +private fun SettingProfileSection( + nickname: String, + profileImageUrl: String?, + onEditProfileClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .background(FlintTheme.colors.background) + .padding(horizontal = 16.dp) + .padding(top = 12.dp, bottom = 28.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProfileImage( + imageUrl = profileImageUrl, + modifier = Modifier.size(56.dp), + ) + + Spacer(Modifier.width(12.dp)) + + Text( + text = nickname, + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.white, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(12.dp)) + + FlintMediumButton( + text = "프로필 수정", + state = FlintButtonState.ColorOutline, + onClick = onEditProfileClick, + textStyle = FlintTheme.typography.body2M14, + modifier = Modifier.weight(0.8f), + ) + } +} + +@Composable +private fun SettingMenuItem( + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + trailingContent: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = if(label == "이용약관") 25.dp else 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.white, + modifier = Modifier.weight(1f), + ) + trailingContent() + } +} + +@Composable +private fun SettingConfirmDialog( + title: String, + message: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + confirmColor: Color = FlintTheme.colors.error500, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = FlintTheme.colors.gray800, + title = { + Text( + text = title, + style = FlintTheme.typography.head3Sb18, + color = FlintTheme.colors.white, + ) + }, + text = { + Text( + text = message, + style = FlintTheme.typography.body2R14, + color = FlintTheme.colors.gray300, + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = confirmText, + style = FlintTheme.typography.body1M16, + color = confirmColor, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = "취소", + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.gray300, + ) + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + FlintTheme { + SettingScreen( + uiState = SettingUiState( + nickname = "한비두비세비", + profileImageUrl = null, + email = "flint@flint.com", + ), + onBackClick = {}, + onEditProfileClick = {}, + onLogoutClick = {}, + onWithdrawClick = {}, + onLogoutConfirm = {}, + onLogoutDismiss = {}, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt b/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt new file mode 100644 index 00000000..fd649d8e --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt @@ -0,0 +1,9 @@ +package com.flint.presentation.setting + +data class SettingUiState( + val nickname: String = "", + val profileImageUrl: String? = null, + val email: String = "", + val isLogoutDialogVisible: Boolean = false, + val isWithdrawDialogVisible: Boolean = false, +) diff --git a/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt new file mode 100644 index 00000000..cf911d18 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt @@ -0,0 +1,82 @@ +package com.flint.presentation.setting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flint.core.common.util.DataStoreKey.USER_EMAIL +import com.flint.core.common.util.DataStoreKey.USER_NAME +import com.flint.data.local.PreferencesManager +import com.flint.domain.repository.AuthRepository +import com.flint.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SettingViewModel @Inject constructor( + private val preferencesManager: PreferencesManager, + private val userRepository: UserRepository, + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SettingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _navigateToLogin = MutableSharedFlow() + val navigateToLogin = _navigateToLogin.asSharedFlow() + + fun loadUserInfo() { + viewModelScope.launch { + val nickname = preferencesManager.getString(USER_NAME).first() + val email = preferencesManager.getString(USER_EMAIL).first() + _uiState.update { it.copy(nickname = nickname, email = email) } + } + + viewModelScope.launch { + userRepository.getUserProfile(userId = null) + .onSuccess { profile -> + _uiState.update { it.copy(profileImageUrl = profile.profileImageUrl) } + } + .onFailure { Timber.e(it) } + } + } + + fun showLogoutDialog() { + _uiState.update { it.copy(isLogoutDialogVisible = true) } + } + + fun dismissLogoutDialog() { + _uiState.update { it.copy(isLogoutDialogVisible = false) } + } + + fun showWithdrawDialog() { + _uiState.update { it.copy(isWithdrawDialogVisible = true) } + } + + fun dismissWithdrawDialog() { + _uiState.update { it.copy(isWithdrawDialogVisible = false) } + } + + fun logout() { + viewModelScope.launch { + authRepository.logout() + .onSuccess { _navigateToLogin.emit(Unit) } + .onFailure { Timber.e(it) } + } + } + + fun withdraw() { + viewModelScope.launch { + authRepository.withdraw() + .onSuccess { _navigateToLogin.emit(Unit) } + .onFailure { Timber.e(it) } + } + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt new file mode 100644 index 00000000..014931cc --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt @@ -0,0 +1,264 @@ +package com.flint.presentation.setting.editprofile + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.vectorResource +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 com.chattymin.pebble.graphemeLength +import com.flint.R +import com.flint.core.designsystem.component.bottomsheet.MenuBottomSheet +import com.flint.core.designsystem.component.bottomsheet.MenuBottomSheetData +import com.flint.core.designsystem.component.button.FlintButtonState +import com.flint.core.designsystem.component.button.FlintLargeButton +import com.flint.core.designsystem.component.image.EditProfileImage +import com.flint.core.designsystem.component.textfield.FlintBasicTextField +import com.flint.core.designsystem.component.toast.ShowToast +import com.flint.core.designsystem.component.topappbar.FlintBackTopAppbar +import com.flint.core.designsystem.theme.FlintTheme + +@Composable +fun EditProfileRoute( + navigateUp: () -> Unit, + viewModel: EditProfileViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadProfile() + } + + LaunchedEffect(Unit) { + viewModel.navigateUp.collect { navigateUp() } + } + + EditProfileScreen( + uiState = uiState, + onBackClick = navigateUp, + onNicknameChange = viewModel::updateNickname, + onCheckNickname = viewModel::checkNicknameDuplication, + onClearError = viewModel::clearNicknameError, + onCompleteClick = viewModel::saveProfile, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditProfileScreen( + uiState: EditProfileUiState, + onBackClick: () -> Unit, + onNicknameChange: (String) -> Unit, + onCheckNickname: () -> Unit, + onClearError: () -> Unit, + onCompleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + var showToast by remember { mutableStateOf(false) } + var toastMessage by remember { mutableStateOf("") } + var isToastSuccess by remember { mutableStateOf(false) } + var showProfileBottomSheet by remember { mutableStateOf(false) } + var selectedImageUri by remember { mutableStateOf(null) } + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) selectedImageUri = uri + } + + LaunchedEffect(uiState.hasError, uiState.errorMessage) { + if (uiState.hasError && uiState.errorMessage != null) { + toastMessage = uiState.errorMessage!! + isToastSuccess = false + showToast = true + } + } + + LaunchedEffect(uiState.isNicknameAvailable) { + if (uiState.isNicknameAvailable == true) { + toastMessage = "사용 가능한 닉네임입니다" + isToastSuccess = true + showToast = true + } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(FlintTheme.colors.background), + ) { + Column(modifier = Modifier.fillMaxSize()) { + FlintBackTopAppbar( + onClick = onBackClick, + title = "프로필 수정", + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + EditProfileImage( + imageUrl = selectedImageUri?.toString() ?: uiState.profileImageUrl ?: "", + onEditClick = { showProfileBottomSheet = true }, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FlintBasicTextField( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + placeholder = "닉네임", + value = uiState.nickname, + singleLine = true, + maxLines = 1, + maxLength = EditProfileUiState.MAX_LENGTH, + onValueChange = onNicknameChange, + borderColor = if (!uiState.isFormatValid || uiState.isNicknameAvailable == false) { + FlintTheme.colors.error500 + } else { + Color.Unspecified + }, + trailingContent = { + Text( + text = "${uiState.nickname.graphemeLength}/${EditProfileUiState.MAX_LENGTH}", + style = FlintTheme.typography.body1R16, + color = FlintTheme.colors.gray300, + ) + }, + ) + + Spacer(modifier = Modifier.padding(start = 8.dp)) + + Box( + modifier = Modifier + .fillMaxHeight() + .clip(RoundedCornerShape(8.dp)) + .background( + if (uiState.canCheckNickname) FlintTheme.colors.primary400 + else FlintTheme.colors.gray700 + ) + .clickable(enabled = uiState.canCheckNickname) { + keyboardController?.hide() + onCheckNickname() + } + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "확인", + color = if (uiState.canCheckNickname) FlintTheme.colors.white else FlintTheme.colors.gray400, + style = if (uiState.canCheckNickname) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, + ) + } + } + } + + FlintLargeButton( + text = "완료", + state = if (uiState.canComplete) FlintButtonState.Able else FlintButtonState.Disable, + onClick = onCompleteClick, + enabled = uiState.canComplete, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 20.dp), + ) + } + + if (showProfileBottomSheet) { + MenuBottomSheet( + menuBottomSheetDataList = listOf( + MenuBottomSheetData( + label = "갤러리에서 선택", + clickAction = { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ), + MenuBottomSheetData( + label = "프로필 사진 삭제", + color = FlintTheme.colors.error500, + clickAction = { selectedImageUri = null } + ), + ), + onDismiss = { showProfileBottomSheet = false } + ) + } + + if (showToast) { + ShowToast( + text = toastMessage, + imageVector = ImageVector.vectorResource( + if (isToastSuccess) R.drawable.ic_check else R.drawable.ic_x + ), + paddingValues = PaddingValues.Zero, + yOffset = 100.dp, + hide = { + showToast = false + if (!isToastSuccess) onClearError() + }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EditProfileScreenPreview() { + FlintTheme { + EditProfileScreen( + uiState = EditProfileUiState( + initialNickname = "한비두비세비", + nickname = "한비두비세비", + profileImageUrl = null, + ), + onBackClick = {}, + onNicknameChange = {}, + onCheckNickname = {}, + onClearError = {}, + onCompleteClick = {}, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt new file mode 100644 index 00000000..da070cc4 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt @@ -0,0 +1,42 @@ +package com.flint.presentation.setting.editprofile + +data class EditProfileUiState( + val initialNickname: String = "", + val nickname: String = "", + val profileImageUrl: String? = null, + val isFormatValid: Boolean = true, + val isNicknameAvailable: Boolean? = null, + val nicknameErrorType: EditProfileNicknameErrorType? = null, +) { + companion object { + const val MAX_LENGTH = 8 + const val MIN_LENGTH = 2 + private val NICKNAME_REGEX = Regex("^[가-힣a-zA-Z]+$") + + fun isValidFormat(nickname: String): Boolean = + nickname.isEmpty() || NICKNAME_REGEX.matches(nickname) + } + + val isNicknameChanged: Boolean get() = nickname != initialNickname + val isLengthValid: Boolean get() = nickname.length >= MIN_LENGTH + + val hasError: Boolean get() = nicknameErrorType != null + val errorMessage: String? get() = when (nicknameErrorType) { + EditProfileNicknameErrorType.DUPLICATE -> "이미 사용 중인 닉네임입니다" + EditProfileNicknameErrorType.INVALID_FORMAT -> "사용할 수 없는 닉네임입니다" + null -> null + } + + val canCheckNickname: Boolean get() = isLengthValid && isFormatValid && isNicknameChanged + + val canComplete: Boolean get() = when { + !isNicknameChanged -> true + !isLengthValid || !isFormatValid -> false + else -> isNicknameAvailable == true + } +} + +enum class EditProfileNicknameErrorType { + DUPLICATE, + INVALID_FORMAT, +} diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt new file mode 100644 index 00000000..a946d393 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt @@ -0,0 +1,101 @@ +package com.flint.presentation.setting.editprofile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flint.core.common.util.DataStoreKey.USER_NAME +import com.flint.data.local.PreferencesManager +import com.flint.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class EditProfileViewModel @Inject constructor( + private val preferencesManager: PreferencesManager, + private val userRepository: UserRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(EditProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _navigateUp = MutableSharedFlow() + val navigateUp = _navigateUp.asSharedFlow() + + fun loadProfile() { + viewModelScope.launch { + val nickname = preferencesManager.getString(USER_NAME).first() + _uiState.update { it.copy(initialNickname = nickname, nickname = nickname) } + } + + viewModelScope.launch { + userRepository.getUserProfile(userId = null) + .onSuccess { profile -> + _uiState.update { it.copy(profileImageUrl = profile.profileImageUrl) } + } + .onFailure { Timber.e(it) } + } + } + + fun updateNickname(nickname: String) { + if (nickname.length <= EditProfileUiState.MAX_LENGTH) { + val isFormatValid = EditProfileUiState.isValidFormat(nickname) + _uiState.update { current -> + current.copy( + nickname = nickname, + isFormatValid = isFormatValid, + isNicknameAvailable = null, + nicknameErrorType = when { + !isFormatValid && nickname.isNotEmpty() -> EditProfileNicknameErrorType.INVALID_FORMAT + else -> null + }, + ) + } + } + } + + fun checkNicknameDuplication() { + val state = _uiState.value + if (!state.isFormatValid || !state.isNicknameChanged) return + + viewModelScope.launch { + userRepository.checkNickname(state.nickname) + .onSuccess { result -> + _uiState.update { current -> + current.copy( + isNicknameAvailable = result.isAvailable, + nicknameErrorType = if (!result.isAvailable) { + EditProfileNicknameErrorType.DUPLICATE + } else { + null + }, + ) + } + } + .onFailure { Timber.e(it) } + } + } + + fun clearNicknameError() { + _uiState.update { it.copy(nicknameErrorType = null) } + } + + fun saveProfile() { + val state = _uiState.value + if (!state.canComplete) return + + viewModelScope.launch { + if (state.isNicknameChanged) { + preferencesManager.saveString(USER_NAME, state.nickname) + } + _navigateUp.emit(Unit) + } + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/navigation/EditProfileNavigation.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/navigation/EditProfileNavigation.kt new file mode 100644 index 00000000..99ff0e84 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/navigation/EditProfileNavigation.kt @@ -0,0 +1,22 @@ +package com.flint.presentation.setting.editprofile.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.flint.core.navigation.Route +import com.flint.presentation.setting.editprofile.EditProfileRoute + +fun NavController.navigateToEditProfile(navOptions: NavOptions? = null) { + navigate(Route.EditProfile, navOptions) +} + +fun NavGraphBuilder.editProfileNavGraph( + navigateUp: () -> Unit, +) { + composable { + EditProfileRoute( + navigateUp = navigateUp, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/navigation/SettingNavigation.kt b/app/src/main/java/com/flint/presentation/setting/navigation/SettingNavigation.kt new file mode 100644 index 00000000..8ada90c6 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/navigation/SettingNavigation.kt @@ -0,0 +1,28 @@ +package com.flint.presentation.setting.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.flint.core.navigation.Route +import com.flint.presentation.setting.SettingRoute + +fun NavController.navigateToSetting(navOptions: NavOptions? = null) { + navigate(Route.Setting, navOptions) +} + +fun NavGraphBuilder.settingNavGraph( + navigateUp: () -> Unit, + navigateToLogin: () -> Unit, + navigateToEditProfile: () -> Unit, + navigateToWithdraw: () -> Unit, +) { + composable { + SettingRoute( + navigateUp = navigateUp, + navigateToLogin = navigateToLogin, + navigateToEditProfile = navigateToEditProfile, + navigateToWithdraw = navigateToWithdraw, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt new file mode 100644 index 00000000..e30dfba1 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt @@ -0,0 +1,195 @@ +package com.flint.presentation.setting.withdraw + +import androidx.compose.foundation.background +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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 com.flint.R +import com.flint.core.common.extension.noRippleClickable +import com.flint.core.designsystem.component.button.FlintButtonState +import com.flint.core.designsystem.component.button.FlintLargeButton +import com.flint.core.designsystem.component.topappbar.FlintBackTopAppbar +import com.flint.core.designsystem.theme.FlintTheme + +@Composable +fun WithdrawRoute( + navigateUp: () -> Unit, + navigateToLogin: () -> Unit, + viewModel: WithdrawViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.navigateToLogin.collect { navigateToLogin() } + } + + WithdrawScreen( + uiState = uiState, + onBackClick = navigateUp, + onToggleConfirm = viewModel::toggleConfirm, + onWithdrawClick = viewModel::withdraw, + ) +} + +@Composable +private fun WithdrawScreen( + uiState: WithdrawUiState, + onBackClick: () -> Unit, + onToggleConfirm: () -> Unit, + onWithdrawClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(FlintTheme.colors.background), + ) { + FlintBackTopAppbar( + onClick = onBackClick, + title = "탈퇴하기", + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 28.dp), + ) { + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "계정 삭제 시\nFlint에서 기록한 모든 정보가 사라져요", + style = FlintTheme.typography.head1Sb22, + color = FlintTheme.colors.white, + ) + + Spacer(modifier = Modifier.height(28.dp)) + + WithdrawNoticeList() + } + + Spacer(Modifier.height(28.dp)) + + HorizontalDivider( + color = FlintTheme.colors.gray600, + thickness = 4.dp, + ) + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable(onClick = onToggleConfirm) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "위 유의사항을 확인했습니다", + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.white, + modifier = Modifier.weight(1f), + ) + + WithdrawCheckBox(checked = uiState.isConfirmed) + } + + Spacer(modifier = Modifier.weight(1f)) + + FlintLargeButton( + text = "탈퇴하기", + state = if (uiState.isConfirmed) FlintButtonState.Destructive else FlintButtonState.Disable, + onClick = onWithdrawClick, + enabled = uiState.isConfirmed && !uiState.isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 20.dp), + ) + } +} + +@Composable +private fun WithdrawNoticeList() { + val notices = listOf( + "회원 탈퇴 시 즉시 탈퇴 처리되며, 서비스 이용이 제한됩니다.", + "저장한 작품 및 등록한 컬렉션, 취향 키워드 등 모든 이용 기록이 삭제되며, 다시 복구할 수 없습니다.", + "동일한 계정 정보로 재가입하더라도 이전의 이용 기록은 복원되지 않습니다.", + "관련 법령에 따라 일부 정보는 일정 기간 보관될 수 있으며, 해당 정보는 법적 의무 이외의 목적으로 사용되지 않습니다.", + ) + + Column { + notices.forEachIndexed { index, text -> + Row { + Text( + text = "${index + 1}. ", + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.gray300, + ) + Text( + text = text, + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.gray300, + modifier = Modifier.offset(y = (-3).dp) + ) + } + } + } +} + +@Composable +private fun WithdrawCheckBox(checked: Boolean) { + Icon( + imageVector = ImageVector.vectorResource( + if (checked) R.drawable.ic_check_fill else R.drawable.ic_check_empty + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(48.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun WithdrawScreenEmptyPreview() { + FlintTheme { + WithdrawScreen( + uiState = WithdrawUiState(isConfirmed = false), + onBackClick = {}, + onToggleConfirm = {}, + onWithdrawClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun WithdrawScreenFilledPreview() { + FlintTheme { + WithdrawScreen( + uiState = WithdrawUiState(isConfirmed = true), + onBackClick = {}, + onToggleConfirm = {}, + onWithdrawClick = {}, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt new file mode 100644 index 00000000..4ae681c2 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt @@ -0,0 +1,50 @@ +package com.flint.presentation.setting.withdraw + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flint.domain.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +data class WithdrawUiState( + val isConfirmed: Boolean = false, + val isLoading: Boolean = false, +) + +@HiltViewModel +class WithdrawViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(WithdrawUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _navigateToLogin = MutableSharedFlow() + val navigateToLogin = _navigateToLogin.asSharedFlow() + + fun toggleConfirm() { + _uiState.update { it.copy(isConfirmed = !it.isConfirmed) } + } + + fun withdraw() { + if (!_uiState.value.isConfirmed) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + authRepository.withdraw() + .onSuccess { _navigateToLogin.emit(Unit) } + .onFailure { + Timber.e(it) + _uiState.update { state -> state.copy(isLoading = false) } + } + } + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt new file mode 100644 index 00000000..8154169c --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt @@ -0,0 +1,24 @@ +package com.flint.presentation.setting.withdraw.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.flint.core.navigation.Route +import com.flint.presentation.setting.withdraw.WithdrawRoute + +fun NavController.navigateToWithdraw(navOptions: NavOptions? = null) { + navigate(Route.Withdraw, navOptions) +} + +fun NavGraphBuilder.withdrawNavGraph( + navigateUp: () -> Unit, + navigateToLogin: () -> Unit, +) { + composable { + WithdrawRoute( + navigateUp = navigateUp, + navigateToLogin = navigateToLogin, + ) + } +} From 974a9594b2dfcd945e55c23bad513d248fc54189 Mon Sep 17 00:00:00 2001 From: kimjw Date: Wed, 20 May 2026 23:02:32 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=97=B0=EB=8F=99=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Constants에 USER_EMAIL DataStore 키 추가 - AuthRepository에 logout() 함수 추가 - FlintBasicButton/FlintMediumButton에 textStyle 파라미터 추가 - ProfileScreen/ProfileNavigation에 navigateToSetting 연결 - ic_kakao_full 드로어블 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/flint/core/common/util/Constants.kt | 1 + .../component/button/FlintBasicButton.kt | 4 +++- .../component/button/FlintMediumButton.kt | 3 +++ .../com/flint/domain/repository/AuthRepository.kt | 5 +++++ .../flint/presentation/profile/ProfileScreen.kt | 2 ++ .../profile/navigation/ProfileNavigation.kt | 2 ++ app/src/main/res/drawable/ic_kakao_full.png | Bin 0 -> 528 bytes 7 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ic_kakao_full.png diff --git a/app/src/main/java/com/flint/core/common/util/Constants.kt b/app/src/main/java/com/flint/core/common/util/Constants.kt index 13fadf40..6f5f9448 100644 --- a/app/src/main/java/com/flint/core/common/util/Constants.kt +++ b/app/src/main/java/com/flint/core/common/util/Constants.kt @@ -5,4 +5,5 @@ object DataStoreKey { const val REFRESH_TOKEN = "refreshToken" const val USER_ID = "userId" const val USER_NAME = "userName" + const val USER_EMAIL = "userEmail" } \ No newline at end of file diff --git a/app/src/main/java/com/flint/core/designsystem/component/button/FlintBasicButton.kt b/app/src/main/java/com/flint/core/designsystem/component/button/FlintBasicButton.kt index 8a75fae5..ce9bbec5 100644 --- a/app/src/main/java/com/flint/core/designsystem/component/button/FlintBasicButton.kt +++ b/app/src/main/java/com/flint/core/designsystem/component/button/FlintBasicButton.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.flint.R @@ -36,6 +37,7 @@ fun FlintBasicButton( state: FlintButtonState, onClick: () -> Unit, modifier: Modifier = Modifier, + textStyle: TextStyle = if (state == FlintButtonState.Able) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, enabled: Boolean = true, contentPadding: PaddingValues, @DrawableRes leadingIconRes: Int? = null, @@ -76,7 +78,7 @@ fun FlintBasicButton( Text( text = text, color = contentColor, - style = if (state == FlintButtonState.Able) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, + style = textStyle, ) } } diff --git a/app/src/main/java/com/flint/core/designsystem/component/button/FlintMediumButton.kt b/app/src/main/java/com/flint/core/designsystem/component/button/FlintMediumButton.kt index 16fd14f9..0ffb6373 100644 --- a/app/src/main/java/com/flint/core/designsystem/component/button/FlintMediumButton.kt +++ b/app/src/main/java/com/flint/core/designsystem/component/button/FlintMediumButton.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.flint.core.designsystem.theme.FlintTheme @@ -21,12 +22,14 @@ fun FlintMediumButton( state: FlintButtonState, onClick: () -> Unit, modifier: Modifier = Modifier, + textStyle: TextStyle = if (state == FlintButtonState.Able) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, enabled: Boolean = true, ) { FlintBasicButton( text = text, state = state, onClick = onClick, + textStyle = textStyle, enabled = enabled, contentPadding = PaddingValues(10.dp), modifier = diff --git a/app/src/main/java/com/flint/domain/repository/AuthRepository.kt b/app/src/main/java/com/flint/domain/repository/AuthRepository.kt index 773b0b86..0a189ec2 100644 --- a/app/src/main/java/com/flint/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/AuthRepository.kt @@ -38,6 +38,11 @@ class AuthRepository @Inject constructor( result } + suspend fun logout(): Result = + suspendRunCatching { + preferencesManager.clearAll() + } + suspend fun withdraw(): Result = suspendRunCatching { api.withdraw() diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt index be81142f..64727274 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt @@ -65,6 +65,7 @@ fun ProfileRoute( navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, navigateToSavedContentList: () -> Unit, // TODO: 스프린트에서 구현 navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToSetting: () -> Unit = {}, viewModel: ProfileViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -105,6 +106,7 @@ fun ProfileRoute( uiState = uiState, onBackClick = navigateUp, onCollectionItemClick = navigateToCollectionDetail, + onSettingsClick = navigateToSetting, onContentItemClick = { contentId -> viewModel.getOttListPerContent(contentId) }, diff --git a/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt b/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt index de8a3fa6..c125d4a9 100644 --- a/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt +++ b/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt @@ -28,6 +28,7 @@ fun NavGraphBuilder.myProfileNavGraph( navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, navigateToSavedContentList: () -> Unit, navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToSetting: () -> Unit = {}, ) { composable { ProfileRoute( @@ -36,6 +37,7 @@ fun NavGraphBuilder.myProfileNavGraph( navigateToCollectionList = navigateToCollectionList, navigateToSavedContentList = navigateToSavedContentList, navigateToCollectionDetail = navigateToCollectionDetail, + navigateToSetting = navigateToSetting, ) } } diff --git a/app/src/main/res/drawable/ic_kakao_full.png b/app/src/main/res/drawable/ic_kakao_full.png new file mode 100644 index 0000000000000000000000000000000000000000..0c77c76025efbb697493fd3d5c632ec9e47397bf GIT binary patch literal 528 zcmV+r0`L8aP)vFN+XCTpIEn|kg_NhZQz#>N*%%!EcEINFj+}e)-ID6 zAh;tEG{Yjot!U>#$*v(|+p_42Y4@abRNdQ@dbd<+V*lXTT{Ajl7|7?N$mJsJj&H2) z!l_xO8ny7%2#`)c;-l^S=o}82DBX-f_$qWHbPs;($0sj6$sF%?8@qc?!NX((0%yOt zd_%vuyKM&3q`)78sFR{IYIw=kW}>ga%4#$qriV*^uf2k04P&Vg@8JwA7GjvsM*JF2 zC)s**1*vinJUjP*R!bt0Ffe2aOihQewjArYLbrW`Qf6qpQn726z7DnSZNx`RoS%(C z2tu!r`U(yGF8*hfdBFu%?MG&eM|Qd003L0}#z literal 0 HcmV?d00001 From e839a1ec9f786cac138974b2e90d6dea449fbb9a Mon Sep 17 00:00:00 2001 From: kimjw Date: Wed, 3 Jun 2026 00:12:48 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 이미지 변경: Presigned URL → S3 업로드 → PUT /api/v1/users/me/profile-image - 닉네임 수정: PUT /api/v1/users/me/nickname API 연결 및 로컬 저장소 동기화 - 자음/모음 단독 입력 시 확인/완료 버튼 비활성화 (온보딩과 동일한 로직) - 닉네임 정규식 자모 중간 입력 허용으로 수정 - 토스트 로직 온보딩 프로필 화면과 동일하게 통일 - AuthRepository logout() 추가 - data 필드 없는 응답 처리를 위한 BaseEmptyResponse 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/com/flint/data/api/UserApi.kt | 17 ++++++ .../flint/data/dto/base/BaseEmptyResponse.kt | 12 ++++ .../user/request/UpdateNicknameRequestDto.kt | 10 ++++ .../request/UpdateProfileImageRequestDto.kt | 10 ++++ .../flint/domain/repository/AuthRepository.kt | 5 ++ .../flint/domain/repository/UserRepository.kt | 16 +++++ .../presentation/setting/SettingScreen.kt | 2 + .../setting/editprofile/EditProfileScreen.kt | 25 ++++---- .../setting/editprofile/EditProfileUiState.kt | 13 +++- .../editprofile/EditProfileViewModel.kt | 60 ++++++++++++++++++- 10 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/flint/data/dto/base/BaseEmptyResponse.kt create mode 100644 app/src/main/java/com/flint/data/dto/user/request/UpdateNicknameRequestDto.kt create mode 100644 app/src/main/java/com/flint/data/dto/user/request/UpdateProfileImageRequestDto.kt diff --git a/app/src/main/java/com/flint/data/api/UserApi.kt b/app/src/main/java/com/flint/data/api/UserApi.kt index 37ff8993..7f5ec759 100644 --- a/app/src/main/java/com/flint/data/api/UserApi.kt +++ b/app/src/main/java/com/flint/data/api/UserApi.kt @@ -1,13 +1,18 @@ package com.flint.data.api +import com.flint.data.dto.base.BaseEmptyResponse import com.flint.data.dto.base.BaseResponse import com.flint.data.dto.content.response.BookmarkedContentListResponseDto +import com.flint.data.dto.user.request.UpdateNicknameRequestDto +import com.flint.data.dto.user.request.UpdateProfileImageRequestDto import com.flint.data.dto.user.response.NicknameCheckResponseDto import com.flint.data.dto.user.response.BookmarkedCollectionListResponseDto import com.flint.data.dto.user.response.CreatedCollectionListResponseDto import com.flint.data.dto.user.response.UserKeywordsResponseDto import com.flint.data.dto.user.response.UserProfileResponseDto +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -57,4 +62,16 @@ interface UserApi { ): BaseResponse // 취향 키워드 재계산 + + // 닉네임 수정 + @PUT("/api/v1/users/me/nickname") + suspend fun updateNickname( + @Body requestDto: UpdateNicknameRequestDto, + ): BaseEmptyResponse + + // 프로필 이미지 수정 + @PUT("/api/v1/users/me/profile-image") + suspend fun updateProfileImage( + @Body requestDto: UpdateProfileImageRequestDto, + ): BaseEmptyResponse } diff --git a/app/src/main/java/com/flint/data/dto/base/BaseEmptyResponse.kt b/app/src/main/java/com/flint/data/dto/base/BaseEmptyResponse.kt new file mode 100644 index 00000000..ab2eeee3 --- /dev/null +++ b/app/src/main/java/com/flint/data/dto/base/BaseEmptyResponse.kt @@ -0,0 +1,12 @@ +package com.flint.data.dto.base + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseEmptyResponse( + @SerialName("status") + val status: Int, + @SerialName("message") + val message: String, +) diff --git a/app/src/main/java/com/flint/data/dto/user/request/UpdateNicknameRequestDto.kt b/app/src/main/java/com/flint/data/dto/user/request/UpdateNicknameRequestDto.kt new file mode 100644 index 00000000..4dbd1372 --- /dev/null +++ b/app/src/main/java/com/flint/data/dto/user/request/UpdateNicknameRequestDto.kt @@ -0,0 +1,10 @@ +package com.flint.data.dto.user.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateNicknameRequestDto( + @SerialName("nickname") + val nickname: String, +) diff --git a/app/src/main/java/com/flint/data/dto/user/request/UpdateProfileImageRequestDto.kt b/app/src/main/java/com/flint/data/dto/user/request/UpdateProfileImageRequestDto.kt new file mode 100644 index 00000000..053dc8ca --- /dev/null +++ b/app/src/main/java/com/flint/data/dto/user/request/UpdateProfileImageRequestDto.kt @@ -0,0 +1,10 @@ +package com.flint.data.dto.user.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateProfileImageRequestDto( + @SerialName("profileImage") + val profileImage: String, +) diff --git a/app/src/main/java/com/flint/domain/repository/AuthRepository.kt b/app/src/main/java/com/flint/domain/repository/AuthRepository.kt index c8ae60fd..04eb24c9 100644 --- a/app/src/main/java/com/flint/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/AuthRepository.kt @@ -39,6 +39,11 @@ class AuthRepository @Inject constructor( result } + suspend fun logout(): Result = + suspendRunCatching { + preferencesManager.clearAll() + } + // :TODO 일단 10으로 고정해둠 수정예정 suspend fun withdraw(agreedTermsIds: List = listOf("10")): Result = suspendRunCatching { diff --git a/app/src/main/java/com/flint/domain/repository/UserRepository.kt b/app/src/main/java/com/flint/domain/repository/UserRepository.kt index d2883dfa..67b71d47 100644 --- a/app/src/main/java/com/flint/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/UserRepository.kt @@ -3,6 +3,8 @@ package com.flint.domain.repository import com.flint.core.common.util.DataStoreKey.USER_ID import com.flint.core.common.util.suspendRunCatching import com.flint.data.api.UserApi +import com.flint.data.dto.user.request.UpdateNicknameRequestDto +import com.flint.data.dto.user.request.UpdateProfileImageRequestDto import com.flint.data.local.PreferencesManager import com.flint.domain.mapper.collection.toModel import com.flint.domain.mapper.content.toModel @@ -64,4 +66,18 @@ class UserRepository @Inject constructor( suspendRunCatching { apiService.checkNickname(nickname).data.toModel() } + + // 닉네임 수정 + suspend fun updateNickname(nickname: String): Result = + suspendRunCatching { + apiService.updateNickname(UpdateNicknameRequestDto(nickname = nickname)) + Unit + } + + // 프로필 이미지 수정 + suspend fun updateProfileImage(imageKey: String): Result = + suspendRunCatching { + apiService.updateProfileImage(UpdateProfileImageRequestDto(profileImage = imageKey)) + Unit + } } diff --git a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt index f0b55ce3..79d6bb87 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt @@ -11,6 +11,7 @@ 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.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider @@ -85,6 +86,7 @@ private fun SettingScreen( Box( modifier = modifier .fillMaxSize() + .systemBarsPadding() .background(FlintTheme.colors.background), ) { Column(modifier = Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt index 014931cc..71ec82a1 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt @@ -15,7 +15,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text @@ -68,7 +71,7 @@ fun EditProfileRoute( onBackClick = navigateUp, onNicknameChange = viewModel::updateNickname, onCheckNickname = viewModel::checkNicknameDuplication, - onClearError = viewModel::clearNicknameError, + onProfileImageSelected = viewModel::updateProfileImage, onCompleteClick = viewModel::saveProfile, ) } @@ -80,7 +83,7 @@ private fun EditProfileScreen( onBackClick: () -> Unit, onNicknameChange: (String) -> Unit, onCheckNickname: () -> Unit, - onClearError: () -> Unit, + onProfileImageSelected: (Uri?) -> Unit, onCompleteClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -89,17 +92,16 @@ private fun EditProfileScreen( var toastMessage by remember { mutableStateOf("") } var isToastSuccess by remember { mutableStateOf(false) } var showProfileBottomSheet by remember { mutableStateOf(false) } - var selectedImageUri by remember { mutableStateOf(null) } val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri -> - if (uri != null) selectedImageUri = uri + if (uri != null) onProfileImageSelected(uri) } LaunchedEffect(uiState.hasError, uiState.errorMessage) { if (uiState.hasError && uiState.errorMessage != null) { - toastMessage = uiState.errorMessage!! + toastMessage = uiState.errorMessage ?: "" isToastSuccess = false showToast = true } @@ -116,6 +118,8 @@ private fun EditProfileScreen( Box( modifier = modifier .fillMaxSize() + .systemBarsPadding() + .imePadding() .background(FlintTheme.colors.background), ) { Column(modifier = Modifier.fillMaxSize()) { @@ -133,7 +137,7 @@ private fun EditProfileScreen( Spacer(modifier = Modifier.height(32.dp)) EditProfileImage( - imageUrl = selectedImageUri?.toString() ?: uiState.profileImageUrl ?: "", + imageUrl = uiState.profileImageUri?.toString() ?: uiState.profileImageUrl ?: "", onEditClick = { showProfileBottomSheet = true }, ) @@ -220,7 +224,7 @@ private fun EditProfileScreen( MenuBottomSheetData( label = "프로필 사진 삭제", color = FlintTheme.colors.error500, - clickAction = { selectedImageUri = null } + clickAction = { onProfileImageSelected(null) } ), ), onDismiss = { showProfileBottomSheet = false } @@ -235,10 +239,7 @@ private fun EditProfileScreen( ), paddingValues = PaddingValues.Zero, yOffset = 100.dp, - hide = { - showToast = false - if (!isToastSuccess) onClearError() - }, + hide = { showToast = false }, ) } } @@ -257,7 +258,7 @@ private fun EditProfileScreenPreview() { onBackClick = {}, onNicknameChange = {}, onCheckNickname = {}, - onClearError = {}, + onProfileImageSelected = {}, onCompleteClick = {}, ) } diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt index da070cc4..911908a8 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt @@ -1,9 +1,12 @@ package com.flint.presentation.setting.editprofile +import android.net.Uri + data class EditProfileUiState( val initialNickname: String = "", val nickname: String = "", val profileImageUrl: String? = null, + val profileImageUri: Uri? = null, val isFormatValid: Boolean = true, val isNicknameAvailable: Boolean? = null, val nicknameErrorType: EditProfileNicknameErrorType? = null, @@ -11,7 +14,8 @@ data class EditProfileUiState( companion object { const val MAX_LENGTH = 8 const val MIN_LENGTH = 2 - private val NICKNAME_REGEX = Regex("^[가-힣a-zA-Z]+$") + private val NICKNAME_REGEX = Regex("^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z]+$") + private val STANDALONE_KOREAN_REGEX = Regex("[ㄱ-ㅎㅏ-ㅣ]") fun isValidFormat(nickname: String): Boolean = nickname.isEmpty() || NICKNAME_REGEX.matches(nickname) @@ -20,6 +24,9 @@ data class EditProfileUiState( val isNicknameChanged: Boolean get() = nickname != initialNickname val isLengthValid: Boolean get() = nickname.length >= MIN_LENGTH + val hasStandaloneKorean: Boolean + get() = STANDALONE_KOREAN_REGEX.containsMatchIn(nickname) + val hasError: Boolean get() = nicknameErrorType != null val errorMessage: String? get() = when (nicknameErrorType) { EditProfileNicknameErrorType.DUPLICATE -> "이미 사용 중인 닉네임입니다" @@ -27,11 +34,11 @@ data class EditProfileUiState( null -> null } - val canCheckNickname: Boolean get() = isLengthValid && isFormatValid && isNicknameChanged + val canCheckNickname: Boolean get() = isLengthValid && isFormatValid && isNicknameChanged && !hasStandaloneKorean val canComplete: Boolean get() = when { !isNicknameChanged -> true - !isLengthValid || !isFormatValid -> false + !isLengthValid || !isFormatValid || hasStandaloneKorean -> false else -> isNicknameAvailable == true } } diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt index a946d393..2a508f7e 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt @@ -1,11 +1,18 @@ package com.flint.presentation.setting.editprofile +import android.content.Context +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flint.core.common.util.DataStoreKey.USER_NAME import com.flint.data.local.PreferencesManager +import com.flint.domain.repository.StorageRepository import com.flint.domain.repository.UserRepository +import com.flint.domain.type.FileExtension +import com.flint.domain.type.StoragePathType import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -14,13 +21,16 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @HiltViewModel class EditProfileViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val preferencesManager: PreferencesManager, private val userRepository: UserRepository, + private val storageRepository: StorageRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(EditProfileUiState()) @@ -61,6 +71,10 @@ class EditProfileViewModel @Inject constructor( } } + fun updateProfileImage(uri: Uri?) { + _uiState.update { it.copy(profileImageUri = uri) } + } + fun checkNicknameDuplication() { val state = _uiState.value if (!state.isFormatValid || !state.isNicknameChanged) return @@ -92,10 +106,54 @@ class EditProfileViewModel @Inject constructor( if (!state.canComplete) return viewModelScope.launch { + if (state.profileImageUri != null) { + uploadProfileImage(state.profileImageUri) + } if (state.isNicknameChanged) { - preferencesManager.saveString(USER_NAME, state.nickname) + userRepository.updateNickname(state.nickname) + .onSuccess { preferencesManager.saveString(USER_NAME, state.nickname) } + .onFailure { Timber.e(it, "Failed to update nickname") } } _navigateUp.emit(Unit) } } + + private suspend fun uploadProfileImage(uri: Uri) { + val mimeType = withContext(Dispatchers.IO) { + context.contentResolver.getType(uri) + } ?: "image/jpeg" + + val extension = mimeTypeToFileExtension(mimeType) + + val presignedUrl = storageRepository.getPresignedUrl( + pathType = StoragePathType.USER_PROFILE, + extension = extension, + ).getOrElse { error -> + Timber.e(error, "Failed to get presigned URL") + return + } + + val imageBytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } ?: return + + storageRepository.uploadToS3( + uploadUrl = presignedUrl.uploadUrl, + imageBytes = imageBytes, + mimeType = mimeType, + ).getOrElse { error -> + Timber.e(error, "Failed to upload profile image to S3") + return + } + + userRepository.updateProfileImage(presignedUrl.key) + .onFailure { Timber.e(it, "Failed to update profile image") } + } + + private fun mimeTypeToFileExtension(mimeType: String): FileExtension = when (mimeType) { + "image/png" -> FileExtension.PNG + "image/gif" -> FileExtension.GIF + "image/webp" -> FileExtension.WEBP + else -> FileExtension.JPEG + } } From 53dd6bd8bbb24b1aa102b8615204a4593893b65c Mon Sep 17 00:00:00 2001 From: kimjw Date: Sun, 7 Jun 2026 20:50:01 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=95=BD=EA=B4=80?= =?UTF-8?q?/=EC=A0=95=EC=B1=85=20=EB=A7=81=ED=81=AC=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 피그마 디자인에 맞춰 로그아웃 확인 다이얼로그를 TwoButtonModal 컴포넌트로 교체하고, 개인정보 정책/이용약관 메뉴에 외부 링크 연결, 탈퇴 화면에 시스템 바 패딩을 적용했다. Co-Authored-By: Claude Sonnet 4.6 --- .../presentation/setting/SettingScreen.kt | 102 ++++++------------ .../setting/withdraw/WithdrawScreen.kt | 2 + 2 files changed, 34 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt index 79d6bb87..fcf77952 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt @@ -13,18 +13,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import android.content.Intent +import android.net.Uri import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -35,6 +34,7 @@ import com.flint.core.common.extension.noRippleClickable import com.flint.core.designsystem.component.button.FlintButtonState import com.flint.core.designsystem.component.button.FlintMediumButton import com.flint.core.designsystem.component.image.ProfileImage +import com.flint.core.designsystem.component.modal.TwoButtonModal import com.flint.core.designsystem.component.topappbar.FlintBackTopAppbar import com.flint.core.designsystem.theme.FlintTheme @@ -83,6 +83,8 @@ private fun SettingScreen( onLogoutDismiss: () -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + Box( modifier = modifier .fillMaxSize() @@ -107,31 +109,37 @@ private fun SettingScreen( SettingMenuItem( label = "계정", trailingContent = { - if (uiState.email.isNotEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = uiState.email, - style = FlintTheme.typography.body1M16, - color = FlintTheme.colors.white, - ) - Spacer(Modifier.width(4.dp)) - Image( - painter = painterResource(R.drawable.ic_kakao_full), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.ic_kakao_full), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) } }, ) HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) - SettingMenuItem(label = "개인정보 정책") + SettingMenuItem( + label = "개인정보 정책", + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://artistic-bacon-a40.notion.site/35650cdb714e804cb642eb2cc576a62c")) + ) + }, + ) HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) - SettingMenuItem(label = "이용약관") + SettingMenuItem( + label = "이용약관", + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://artistic-bacon-a40.notion.site/35650cdb714e8054a69bcbd110ed19dd?source=copy_link")) + ) + }, + ) HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) @@ -158,12 +166,13 @@ private fun SettingScreen( } if (uiState.isLogoutDialogVisible) { - SettingConfirmDialog( - title = "로그아웃", - message = "로그아웃 하시겠어요?", + TwoButtonModal( + message = "로그아웃 하시겠습니까?", + cancelText = "취소", confirmText = "로그아웃", onConfirm = onLogoutConfirm, onDismiss = onLogoutDismiss, + icon = R.drawable.ic_gradient_people, ) } @@ -233,53 +242,6 @@ private fun SettingMenuItem( } } -@Composable -private fun SettingConfirmDialog( - title: String, - message: String, - confirmText: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, - confirmColor: Color = FlintTheme.colors.error500, -) { - AlertDialog( - onDismissRequest = onDismiss, - containerColor = FlintTheme.colors.gray800, - title = { - Text( - text = title, - style = FlintTheme.typography.head3Sb18, - color = FlintTheme.colors.white, - ) - }, - text = { - Text( - text = message, - style = FlintTheme.typography.body2R14, - color = FlintTheme.colors.gray300, - ) - }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text( - text = confirmText, - style = FlintTheme.typography.body1M16, - color = confirmColor, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - text = "취소", - style = FlintTheme.typography.body1M16, - color = FlintTheme.colors.gray300, - ) - } - }, - ) -} - @Preview(showBackground = true) @Composable private fun SettingScreenPreview() { diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt index e30dfba1..49a25eeb 100644 --- a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -63,6 +64,7 @@ private fun WithdrawScreen( Column( modifier = modifier .fillMaxSize() + .systemBarsPadding() .background(FlintTheme.colors.background), ) { FlintBackTopAppbar( From 76cf3662c8ff25d63351fa796b813d74a3bfed0e Mon Sep 17 00:00:00 2001 From: kimjw Date: Fri, 12 Jun 2026 00:15:20 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20=ED=9B=84=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴 성공 시 로그인 화면으로 바로 이동하는 대신 '회원 탈퇴 완료' 화면을 거쳐 첫 화면으로 이동하도록 변경 Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/flint/core/navigation/Route.kt | 3 + .../flint/presentation/main/MainNavHost.kt | 5 + .../flint/presentation/main/MainNavigator.kt | 5 + .../withdraw/WithdrawCompleteScreen.kt | 93 +++++++++++++++++++ .../setting/withdraw/WithdrawScreen.kt | 4 +- .../setting/withdraw/WithdrawViewModel.kt | 6 +- .../navigation/WithdrawCompleteNavigation.kt | 22 +++++ .../withdraw/navigation/WithdrawNavigation.kt | 4 +- 8 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawCompleteScreen.kt create mode 100644 app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawCompleteNavigation.kt diff --git a/app/src/main/java/com/flint/core/navigation/Route.kt b/app/src/main/java/com/flint/core/navigation/Route.kt index aa0657dc..66dd2a26 100644 --- a/app/src/main/java/com/flint/core/navigation/Route.kt +++ b/app/src/main/java/com/flint/core/navigation/Route.kt @@ -67,4 +67,7 @@ interface Route { @Serializable data object Withdraw : Route + + @Serializable + data object WithdrawComplete : Route } diff --git a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt index 33949a04..a4d5a26b 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt @@ -20,6 +20,7 @@ import com.flint.presentation.profile.navigation.profileNavGraph import com.flint.presentation.savedcontent.navigation.savedContentListNavGraph import com.flint.presentation.setting.editprofile.navigation.editProfileNavGraph import com.flint.presentation.setting.navigation.settingNavGraph +import com.flint.presentation.setting.withdraw.navigation.withdrawCompleteNavGraph import com.flint.presentation.setting.withdraw.navigation.withdrawNavGraph import com.flint.presentation.splash.navigation.splashNavGraph @@ -123,6 +124,10 @@ fun MainNavHost( withdrawNavGraph( navigateUp = navigator::navigateUp, + navigateToWithdrawComplete = navigator::navigateToWithdrawComplete, + ) + + withdrawCompleteNavGraph( navigateToLogin = navigator::navigateToLogin, ) } diff --git a/app/src/main/java/com/flint/presentation/main/MainNavigator.kt b/app/src/main/java/com/flint/presentation/main/MainNavigator.kt index 71bc623c..a53f9d39 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavigator.kt @@ -25,6 +25,7 @@ import com.flint.presentation.savedcontent.navigation.navigateToSavedContentList import com.flint.presentation.setting.editprofile.navigation.navigateToEditProfile import com.flint.presentation.setting.navigation.navigateToSetting import com.flint.presentation.setting.withdraw.navigation.navigateToWithdraw +import com.flint.presentation.setting.withdraw.navigation.navigateToWithdrawComplete import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -170,6 +171,10 @@ class MainNavigator( navController.navigateToWithdraw(navOptions) } + fun navigateToWithdrawComplete(navOptions: NavOptions? = clearStackNavOptions) { + navController.navigateToWithdrawComplete(navOptions) + } + fun navigateUp() { navController.navigateUp() } diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawCompleteScreen.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawCompleteScreen.kt new file mode 100644 index 00000000..0ad24e3f --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawCompleteScreen.kt @@ -0,0 +1,93 @@ +package com.flint.presentation.setting.withdraw + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.systemBarsPadding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.flint.R +import com.flint.core.designsystem.component.button.FlintButtonState +import com.flint.core.designsystem.component.button.FlintLargeButton +import com.flint.core.designsystem.theme.FlintTheme + +@Composable +fun WithdrawCompleteRoute( + navigateToLogin: () -> Unit, +) { + WithdrawCompleteScreen( + onNavigateToLogin = navigateToLogin, + ) +} + +@Composable +private fun WithdrawCompleteScreen( + onNavigateToLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .background(FlintTheme.colors.background), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(id = R.drawable.ic_gradient_check), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "회원 탈퇴 완료", + style = FlintTheme.typography.display2M28, + color = FlintTheme.colors.white, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "그동안 Flint를 이용해주셔서\n진심으로 감사 드립니다", + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.white, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.weight(1f)) + + FlintLargeButton( + text = "첫 화면으로 이동하기", + state = FlintButtonState.Able, + onClick = onNavigateToLogin, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 20.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun WithdrawCompleteScreenPreview() { + FlintTheme { + WithdrawCompleteScreen( + onNavigateToLogin = {}, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt index 49a25eeb..2be4a7f2 100644 --- a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt @@ -36,13 +36,13 @@ import com.flint.core.designsystem.theme.FlintTheme @Composable fun WithdrawRoute( navigateUp: () -> Unit, - navigateToLogin: () -> Unit, + navigateToWithdrawComplete: () -> Unit, viewModel: WithdrawViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { - viewModel.navigateToLogin.collect { navigateToLogin() } + viewModel.navigateToWithdrawComplete.collect { navigateToWithdrawComplete() } } WithdrawScreen( diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt index 4ae681c2..ae337695 100644 --- a/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawViewModel.kt @@ -27,8 +27,8 @@ class WithdrawViewModel @Inject constructor( private val _uiState = MutableStateFlow(WithdrawUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _navigateToLogin = MutableSharedFlow() - val navigateToLogin = _navigateToLogin.asSharedFlow() + private val _navigateToWithdrawComplete = MutableSharedFlow() + val navigateToWithdrawComplete = _navigateToWithdrawComplete.asSharedFlow() fun toggleConfirm() { _uiState.update { it.copy(isConfirmed = !it.isConfirmed) } @@ -40,7 +40,7 @@ class WithdrawViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } authRepository.withdraw() - .onSuccess { _navigateToLogin.emit(Unit) } + .onSuccess { _navigateToWithdrawComplete.emit(Unit) } .onFailure { Timber.e(it) _uiState.update { state -> state.copy(isLoading = false) } diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawCompleteNavigation.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawCompleteNavigation.kt new file mode 100644 index 00000000..563a0aa7 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawCompleteNavigation.kt @@ -0,0 +1,22 @@ +package com.flint.presentation.setting.withdraw.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.flint.core.navigation.Route +import com.flint.presentation.setting.withdraw.WithdrawCompleteRoute + +fun NavController.navigateToWithdrawComplete(navOptions: NavOptions? = null) { + navigate(Route.WithdrawComplete, navOptions) +} + +fun NavGraphBuilder.withdrawCompleteNavGraph( + navigateToLogin: () -> Unit, +) { + composable { + WithdrawCompleteRoute( + navigateToLogin = navigateToLogin, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt b/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt index 8154169c..cd7aaf1c 100644 --- a/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/navigation/WithdrawNavigation.kt @@ -13,12 +13,12 @@ fun NavController.navigateToWithdraw(navOptions: NavOptions? = null) { fun NavGraphBuilder.withdrawNavGraph( navigateUp: () -> Unit, - navigateToLogin: () -> Unit, + navigateToWithdrawComplete: () -> Unit, ) { composable { WithdrawRoute( navigateUp = navigateUp, - navigateToLogin = navigateToLogin, + navigateToWithdrawComplete = navigateToWithdrawComplete, ) } } From c6ef4ce8cc10d6b58416789617158511adee185e Mon Sep 17 00:00:00 2001 From: kimjw Date: Fri, 12 Jun 2026 00:46:43 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingUiState에서 미사용 email/isWithdrawDialogVisible 필드 제거 - SettingViewModel에서 미사용 showWithdrawDialog/dismissWithdrawDialog/withdraw 메서드 제거 - SettingScreen URL 하드코딩 → ExternalLinks 상수로 추출 - SettingMenuItem 매직 스트링 패딩 조건 → verticalPadding 파라미터로 교체 - EditProfileViewModel 이미지 업로드 실패 시에도 화면 이탈하던 버그 수정 Co-Authored-By: Claude Sonnet 4.6 --- .../com/flint/core/common/util/Constants.kt | 5 +++++ .../presentation/setting/SettingScreen.kt | 10 +++++---- .../presentation/setting/SettingUiState.kt | 2 -- .../presentation/setting/SettingViewModel.kt | 19 +---------------- .../editprofile/EditProfileViewModel.kt | 21 ++++++++++++------- 5 files changed, 26 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/flint/core/common/util/Constants.kt b/app/src/main/java/com/flint/core/common/util/Constants.kt index 6f5f9448..0aa077ae 100644 --- a/app/src/main/java/com/flint/core/common/util/Constants.kt +++ b/app/src/main/java/com/flint/core/common/util/Constants.kt @@ -6,4 +6,9 @@ object DataStoreKey { const val USER_ID = "userId" const val USER_NAME = "userName" const val USER_EMAIL = "userEmail" +} + +object ExternalLinks { + const val PRIVACY_POLICY_URL = "https://artistic-bacon-a40.notion.site/35650cdb714e804cb642eb2cc576a62c" + const val TERMS_OF_SERVICE_URL = "https://artistic-bacon-a40.notion.site/35650cdb714e8054a69bcbd110ed19dd?source=copy_link" } \ No newline at end of file diff --git a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt index fcf77952..e02ee456 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt @@ -20,7 +20,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import android.content.Intent import android.net.Uri +import androidx.compose.ui.unit.Dp import androidx.compose.ui.Alignment +import com.flint.core.common.util.ExternalLinks import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -125,7 +127,7 @@ private fun SettingScreen( label = "개인정보 정책", onClick = { context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse("https://artistic-bacon-a40.notion.site/35650cdb714e804cb642eb2cc576a62c")) + Intent(Intent.ACTION_VIEW, Uri.parse(ExternalLinks.PRIVACY_POLICY_URL)) ) }, ) @@ -136,7 +138,7 @@ private fun SettingScreen( label = "이용약관", onClick = { context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse("https://artistic-bacon-a40.notion.site/35650cdb714e8054a69bcbd110ed19dd?source=copy_link")) + Intent(Intent.ACTION_VIEW, Uri.parse(ExternalLinks.TERMS_OF_SERVICE_URL)) ) }, ) @@ -222,6 +224,7 @@ private fun SettingProfileSection( private fun SettingMenuItem( label: String, modifier: Modifier = Modifier, + verticalPadding: Dp = 18.dp, onClick: () -> Unit = {}, trailingContent: @Composable () -> Unit = {}, ) { @@ -229,7 +232,7 @@ private fun SettingMenuItem( modifier = modifier .fillMaxWidth() .noRippleClickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = if(label == "이용약관") 25.dp else 18.dp), + .padding(horizontal = 20.dp, vertical = verticalPadding), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -250,7 +253,6 @@ private fun SettingScreenPreview() { uiState = SettingUiState( nickname = "한비두비세비", profileImageUrl = null, - email = "flint@flint.com", ), onBackClick = {}, onEditProfileClick = {}, diff --git a/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt b/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt index fd649d8e..ddf077fc 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt @@ -3,7 +3,5 @@ package com.flint.presentation.setting data class SettingUiState( val nickname: String = "", val profileImageUrl: String? = null, - val email: String = "", val isLogoutDialogVisible: Boolean = false, - val isWithdrawDialogVisible: Boolean = false, ) diff --git a/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt index cf911d18..6f4bc27e 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt @@ -2,7 +2,6 @@ package com.flint.presentation.setting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flint.core.common.util.DataStoreKey.USER_EMAIL import com.flint.core.common.util.DataStoreKey.USER_NAME import com.flint.data.local.PreferencesManager import com.flint.domain.repository.AuthRepository @@ -35,8 +34,7 @@ class SettingViewModel @Inject constructor( fun loadUserInfo() { viewModelScope.launch { val nickname = preferencesManager.getString(USER_NAME).first() - val email = preferencesManager.getString(USER_EMAIL).first() - _uiState.update { it.copy(nickname = nickname, email = email) } + _uiState.update { it.copy(nickname = nickname) } } viewModelScope.launch { @@ -56,14 +54,6 @@ class SettingViewModel @Inject constructor( _uiState.update { it.copy(isLogoutDialogVisible = false) } } - fun showWithdrawDialog() { - _uiState.update { it.copy(isWithdrawDialogVisible = true) } - } - - fun dismissWithdrawDialog() { - _uiState.update { it.copy(isWithdrawDialogVisible = false) } - } - fun logout() { viewModelScope.launch { authRepository.logout() @@ -72,11 +62,4 @@ class SettingViewModel @Inject constructor( } } - fun withdraw() { - viewModelScope.launch { - authRepository.withdraw() - .onSuccess { _navigateToLogin.emit(Unit) } - .onFailure { Timber.e(it) } - } - } } diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt index 2a508f7e..f68c4489 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt @@ -108,17 +108,24 @@ class EditProfileViewModel @Inject constructor( viewModelScope.launch { if (state.profileImageUri != null) { uploadProfileImage(state.profileImageUri) + .onFailure { + Timber.e(it, "Failed to save profile image") + return@launch + } } if (state.isNicknameChanged) { userRepository.updateNickname(state.nickname) .onSuccess { preferencesManager.saveString(USER_NAME, state.nickname) } - .onFailure { Timber.e(it, "Failed to update nickname") } + .onFailure { + Timber.e(it, "Failed to update nickname") + return@launch + } } _navigateUp.emit(Unit) } } - private suspend fun uploadProfileImage(uri: Uri) { + private suspend fun uploadProfileImage(uri: Uri): Result { val mimeType = withContext(Dispatchers.IO) { context.contentResolver.getType(uri) } ?: "image/jpeg" @@ -130,12 +137,12 @@ class EditProfileViewModel @Inject constructor( extension = extension, ).getOrElse { error -> Timber.e(error, "Failed to get presigned URL") - return + return Result.failure(error) } val imageBytes = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - } ?: return + } ?: return Result.failure(IllegalStateException("Failed to open image stream")) storageRepository.uploadToS3( uploadUrl = presignedUrl.uploadUrl, @@ -143,11 +150,11 @@ class EditProfileViewModel @Inject constructor( mimeType = mimeType, ).getOrElse { error -> Timber.e(error, "Failed to upload profile image to S3") - return + return Result.failure(error) } - userRepository.updateProfileImage(presignedUrl.key) - .onFailure { Timber.e(it, "Failed to update profile image") } + return userRepository.updateProfileImage(presignedUrl.key) + .map { } } private fun mimeTypeToFileExtension(mimeType: String): FileExtension = when (mimeType) { From 19a26b863f87e943d518066dfd7d836a2010cb59 Mon Sep 17 00:00:00 2001 From: kimjw Date: Fri, 12 Jun 2026 01:06:07 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=EB=88=84=EB=9D=BD=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isProfileImageDeleted 플래그를 UiState에 추가하여 삭제 상태를 명시적으로 추적 - updateProfileImage(null) 호출 시 삭제 플래그 설정, uri 선택 시 플래그 해제 - 렌더링: 삭제 플래그가 true이면 기존 URL로 폴백하지 않고 빈 이미지 표시 - saveProfile(): 삭제 상태이면 updateProfileImage("") 호출로 서버에 반영 Co-Authored-By: Claude Sonnet 4.6 --- .../setting/editprofile/EditProfileScreen.kt | 6 ++++- .../setting/editprofile/EditProfileUiState.kt | 1 + .../editprofile/EditProfileViewModel.kt | 27 ++++++++++++++----- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt index 71ec82a1..c1a31628 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt @@ -137,7 +137,11 @@ private fun EditProfileScreen( Spacer(modifier = Modifier.height(32.dp)) EditProfileImage( - imageUrl = uiState.profileImageUri?.toString() ?: uiState.profileImageUrl ?: "", + imageUrl = when { + uiState.isProfileImageDeleted -> "" + uiState.profileImageUri != null -> uiState.profileImageUri.toString() + else -> uiState.profileImageUrl ?: "" + }, onEditClick = { showProfileBottomSheet = true }, ) diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt index 911908a8..0f651276 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt @@ -7,6 +7,7 @@ data class EditProfileUiState( val nickname: String = "", val profileImageUrl: String? = null, val profileImageUri: Uri? = null, + val isProfileImageDeleted: Boolean = false, val isFormatValid: Boolean = true, val isNicknameAvailable: Boolean? = null, val nicknameErrorType: EditProfileNicknameErrorType? = null, diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt index f68c4489..db62b18c 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt @@ -72,7 +72,11 @@ class EditProfileViewModel @Inject constructor( } fun updateProfileImage(uri: Uri?) { - _uiState.update { it.copy(profileImageUri = uri) } + if (uri != null) { + _uiState.update { it.copy(profileImageUri = uri, isProfileImageDeleted = false) } + } else { + _uiState.update { it.copy(profileImageUri = null, isProfileImageDeleted = true) } + } } fun checkNicknameDuplication() { @@ -106,12 +110,21 @@ class EditProfileViewModel @Inject constructor( if (!state.canComplete) return viewModelScope.launch { - if (state.profileImageUri != null) { - uploadProfileImage(state.profileImageUri) - .onFailure { - Timber.e(it, "Failed to save profile image") - return@launch - } + when { + state.isProfileImageDeleted -> { + userRepository.updateProfileImage("") + .onFailure { + Timber.e(it, "Failed to delete profile image") + return@launch + } + } + state.profileImageUri != null -> { + uploadProfileImage(state.profileImageUri) + .onFailure { + Timber.e(it, "Failed to save profile image") + return@launch + } + } } if (state.isNicknameChanged) { userRepository.updateNickname(state.nickname)