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..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 @@ -5,4 +5,10 @@ object DataStoreKey { const val REFRESH_TOKEN = "refreshToken" 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/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/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/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/core/navigation/Route.kt b/app/src/main/java/com/flint/core/navigation/Route.kt index 48a191ff..66dd2a26 100644 --- a/app/src/main/java/com/flint/core/navigation/Route.kt +++ b/app/src/main/java/com/flint/core/navigation/Route.kt @@ -58,4 +58,16 @@ 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 + + @Serializable + data object WithdrawComplete : Route } 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/main/MainNavHost.kt b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt index 84a0dfb3..a4d5a26b 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,10 @@ 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.withdrawCompleteNavGraph +import com.flint.presentation.setting.withdraw.navigation.withdrawNavGraph import com.flint.presentation.splash.navigation.splashNavGraph @Composable @@ -96,6 +100,7 @@ fun MainNavHost( navigateToCollectionList = navigator::navigateToCollectionList, navigateToSavedContentList = navigator::navigateToSavedContent, navigateToCollectionDetail = navigator::navigateToCollectionDetail, + navigateToSetting = navigator::navigateToSetting, ) profileNavGraph( @@ -105,6 +110,26 @@ 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, + 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 57d6cf88..a53f9d39 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,10 @@ 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 com.flint.presentation.setting.withdraw.navigation.navigateToWithdrawComplete import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -155,6 +159,22 @@ 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 navigateToWithdrawComplete(navOptions: NavOptions? = clearStackNavOptions) { + navController.navigateToWithdrawComplete(navOptions) + } + fun navigateUp() { navController.navigateUp() } 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/java/com/flint/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt new file mode 100644 index 00000000..e02ee456 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt @@ -0,0 +1,265 @@ +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.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +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.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 +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.modal.TwoButtonModal +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, +) { + val context = LocalContext.current + + Box( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .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 = { + 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 = "개인정보 정책", + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(ExternalLinks.PRIVACY_POLICY_URL)) + ) + }, + ) + + HorizontalDivider(color = FlintTheme.colors.gray700, thickness = 1.dp) + + SettingMenuItem( + label = "이용약관", + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(ExternalLinks.TERMS_OF_SERVICE_URL)) + ) + }, + ) + + 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) { + TwoButtonModal( + message = "로그아웃 하시겠습니까?", + cancelText = "취소", + confirmText = "로그아웃", + onConfirm = onLogoutConfirm, + onDismiss = onLogoutDismiss, + icon = R.drawable.ic_gradient_people, + ) + } + +} + +@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, + verticalPadding: Dp = 18.dp, + onClick: () -> Unit = {}, + trailingContent: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = verticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = FlintTheme.typography.body1M16, + color = FlintTheme.colors.white, + modifier = Modifier.weight(1f), + ) + trailingContent() + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + FlintTheme { + SettingScreen( + uiState = SettingUiState( + nickname = "한비두비세비", + profileImageUrl = null, + ), + 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..ddf077fc --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt @@ -0,0 +1,7 @@ +package com.flint.presentation.setting + +data class SettingUiState( + val nickname: String = "", + val profileImageUrl: String? = null, + val isLogoutDialogVisible: 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..6f4bc27e --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt @@ -0,0 +1,65 @@ +package com.flint.presentation.setting + +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.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() + _uiState.update { it.copy(nickname = nickname) } + } + + 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 logout() { + viewModelScope.launch { + authRepository.logout() + .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..c1a31628 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileScreen.kt @@ -0,0 +1,269 @@ +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.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 +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, + onProfileImageSelected = viewModel::updateProfileImage, + onCompleteClick = viewModel::saveProfile, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditProfileScreen( + uiState: EditProfileUiState, + onBackClick: () -> Unit, + onNicknameChange: (String) -> Unit, + onCheckNickname: () -> Unit, + onProfileImageSelected: (Uri?) -> 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) } + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) onProfileImageSelected(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() + .systemBarsPadding() + .imePadding() + .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 = when { + uiState.isProfileImageDeleted -> "" + uiState.profileImageUri != null -> uiState.profileImageUri.toString() + else -> 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 = { onProfileImageSelected(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 }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EditProfileScreenPreview() { + FlintTheme { + EditProfileScreen( + uiState = EditProfileUiState( + initialNickname = "한비두비세비", + nickname = "한비두비세비", + profileImageUrl = null, + ), + onBackClick = {}, + onNicknameChange = {}, + onCheckNickname = {}, + 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 new file mode 100644 index 00000000..0f651276 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt @@ -0,0 +1,50 @@ +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 isProfileImageDeleted: Boolean = false, + 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]+$") + private val STANDALONE_KOREAN_REGEX = Regex("[ㄱ-ㅎㅏ-ㅣ]") + + 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 hasStandaloneKorean: Boolean + get() = STANDALONE_KOREAN_REGEX.containsMatchIn(nickname) + + 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 && !hasStandaloneKorean + + val canComplete: Boolean get() = when { + !isNicknameChanged -> true + !isLengthValid || !isFormatValid || hasStandaloneKorean -> 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..db62b18c --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileViewModel.kt @@ -0,0 +1,179 @@ +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 +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 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()) + 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 updateProfileImage(uri: Uri?) { + if (uri != null) { + _uiState.update { it.copy(profileImageUri = uri, isProfileImageDeleted = false) } + } else { + _uiState.update { it.copy(profileImageUri = null, isProfileImageDeleted = true) } + } + } + + 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 { + 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) + .onSuccess { preferencesManager.saveString(USER_NAME, state.nickname) } + .onFailure { + Timber.e(it, "Failed to update nickname") + return@launch + } + } + _navigateUp.emit(Unit) + } + } + + private suspend fun uploadProfileImage(uri: Uri): Result { + 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 Result.failure(error) + } + + val imageBytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } ?: return Result.failure(IllegalStateException("Failed to open image stream")) + + storageRepository.uploadToS3( + uploadUrl = presignedUrl.uploadUrl, + imageBytes = imageBytes, + mimeType = mimeType, + ).getOrElse { error -> + Timber.e(error, "Failed to upload profile image to S3") + return Result.failure(error) + } + + return userRepository.updateProfileImage(presignedUrl.key) + .map { } + } + + private fun mimeTypeToFileExtension(mimeType: String): FileExtension = when (mimeType) { + "image/png" -> FileExtension.PNG + "image/gif" -> FileExtension.GIF + "image/webp" -> FileExtension.WEBP + else -> FileExtension.JPEG + } +} 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/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 new file mode 100644 index 00000000..2be4a7f2 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/setting/withdraw/WithdrawScreen.kt @@ -0,0 +1,197 @@ +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.foundation.layout.systemBarsPadding +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, + navigateToWithdrawComplete: () -> Unit, + viewModel: WithdrawViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.navigateToWithdrawComplete.collect { navigateToWithdrawComplete() } + } + + 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() + .systemBarsPadding() + .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..ae337695 --- /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 _navigateToWithdrawComplete = MutableSharedFlow() + val navigateToWithdrawComplete = _navigateToWithdrawComplete.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 { _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 new file mode 100644 index 00000000..cd7aaf1c --- /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, + navigateToWithdrawComplete: () -> Unit, +) { + composable { + WithdrawRoute( + navigateUp = navigateUp, + navigateToWithdrawComplete = navigateToWithdrawComplete, + ) + } +} 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 00000000..0c77c760 Binary files /dev/null and b/app/src/main/res/drawable/ic_kakao_full.png differ