diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5bae15f6..e3b3a290 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,6 +40,7 @@ android { dependencies { implementation(libs.androidx.core.splashscreen) implementation(project(":feature:login")) + implementation(project(":feature:signup")) implementation(project(":feature:home")) implementation(project(":feature:postlist")) implementation(project(":feature:mypage")) diff --git a/app/src/main/java/com/withpeace/withpeace/WithpeaceApp.kt b/app/src/main/java/com/withpeace/withpeace/WithpeaceApp.kt index e5d26dd4..8deda426 100644 --- a/app/src/main/java/com/withpeace/withpeace/WithpeaceApp.kt +++ b/app/src/main/java/com/withpeace/withpeace/WithpeaceApp.kt @@ -19,6 +19,7 @@ import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme import com.withpeace.withpeace.navigation.WithpeaceNavHost import kotlinx.coroutines.launch + @Composable fun WithpeaceApp( startDestination: String, diff --git a/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt index 11b6a7a1..c5889ad6 100644 --- a/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt +++ b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt @@ -10,6 +10,7 @@ import com.app.profileeditor.navigation.profileEditorNavGraph import com.withpeace.withpeace.feature.gallery.navigation.galleryNavGraph import com.withpeace.withpeace.feature.gallery.navigation.navigateToGallery import com.withpeace.withpeace.feature.home.navigation.homeNavGraph +import com.withpeace.withpeace.feature.home.navigation.navigateHome import com.withpeace.withpeace.feature.login.navigation.LOGIN_ROUTE import com.withpeace.withpeace.feature.login.navigation.loginNavGraph import com.withpeace.withpeace.feature.login.navigation.navigateLogin @@ -19,6 +20,8 @@ import com.withpeace.withpeace.feature.mypage.navigation.myPageNavGraph import com.withpeace.withpeace.feature.postlist.navigation.postListGraph import com.withpeace.withpeace.feature.registerpost.navigation.IMAGE_LIST_ARGUMENT import com.withpeace.withpeace.feature.registerpost.navigation.registerPostNavGraph +import com.withpeace.withpeace.feature.signup.navigation.navigateSignUp +import com.withpeace.withpeace.feature.signup.navigation.signUpNavGraph @Composable fun WithpeaceNavHost( @@ -32,7 +35,30 @@ fun WithpeaceNavHost( navController = navController, startDestination = startDestination, ) { - loginNavGraph(onShowSnackBar = onShowSnackBar) + loginNavGraph( + onShowSnackBar = onShowSnackBar, + onSignUpNeeded = { + navController.navigateSignUp() + }, + onLoginSuccess = { + navController.navigateHome() + }, + ) + signUpNavGraph( + onShowSnackBar = onShowSnackBar, + onNavigateToGallery = { + navController.navigateToGallery(imageLimit = 1) + }, + onSignUpSuccess = { + navController.navigateHome( + navOptions = navOptions { + popUpTo(navController.graph.id) { + inclusive = true + } + }, + ) + }, + ) registerPostNavGraph( onShowSnackBar = onShowSnackBar, onCompleteRegisterPost = {}, @@ -97,4 +123,4 @@ fun WithpeaceNavHost( ) postListGraph(onShowSnackBar) } -} +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RoleMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RoleMapper.kt new file mode 100644 index 00000000..b37af56b --- /dev/null +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RoleMapper.kt @@ -0,0 +1,11 @@ +package com.withpeace.withpeace.core.data.mapper + +import com.withpeace.withpeace.core.domain.model.role.Role + +internal fun String.roleToDomain(): Role { + return when (this) { + "GUEST" -> Role.GUEST + "USER" -> Role.USER + else -> Role.UNKNOWN + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultTokenRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultTokenRepository.kt index 23f56bd7..8fd54493 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultTokenRepository.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultTokenRepository.kt @@ -3,11 +3,13 @@ package com.withpeace.withpeace.core.data.repository import com.skydoves.sandwich.message import com.skydoves.sandwich.suspendMapSuccess import com.skydoves.sandwich.suspendOnFailure -import com.withpeace.withpeace.core.datastore.dataStore.TokenPreferenceDataSource +import com.withpeace.withpeace.core.data.mapper.roleToDomain +import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.user.UserPreferenceDataSource +import com.withpeace.withpeace.core.domain.model.role.Role import com.withpeace.withpeace.core.domain.repository.TokenRepository -import com.withpeace.withpeace.core.network.di.request.SignUpRequest +import com.withpeace.withpeace.core.network.di.response.LoginResponse import com.withpeace.withpeace.core.network.di.service.AuthService -import com.withpeace.withpeace.core.network.di.service.UserService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -17,40 +19,33 @@ import javax.inject.Inject class DefaultTokenRepository @Inject constructor( private val tokenPreferenceDataSource: TokenPreferenceDataSource, + private val userPreferenceDataSource: UserPreferenceDataSource, private val authService: AuthService, ) : TokenRepository { override suspend fun isLogin(): Boolean { val token = tokenPreferenceDataSource.accessToken.firstOrNull() - return token != null + val userRole = userPreferenceDataSource.userRole.firstOrNull() + return token != null && userRole?.roleToDomain() == Role.USER //TODO("토큰이 만료되었는지 확인 필요") } - override suspend fun signUp( - email: String, - nickname: String, - onError: (String) -> Unit, - ): Flow = flow { - authService.signUp( - SignUpRequest(email = email, nickname = nickname, deviceToken = null), - ).suspendMapSuccess { - val data = this.data - tokenPreferenceDataSource.updateAccessToken(data.accessToken) - tokenPreferenceDataSource.updateRefreshToken(data.refreshToken) - emit(Unit) - }.suspendOnFailure { onError(message()) } - }.flowOn(Dispatchers.IO) - override fun getTokenByGoogle( idToken: String, onError: (String) -> Unit, - ): Flow = flow { + ): Flow = flow { authService.googleLogin(AUTHORIZATION_FORMAT.format(idToken)).suspendMapSuccess { val data = this.data - tokenPreferenceDataSource.updateAccessToken(data.tokenResponse.accessToken) - tokenPreferenceDataSource.updateRefreshToken(data.tokenResponse.refreshToken) - emit(Unit) + saveLocalLoginInfo(data) + emit(data.role.roleToDomain()) }.suspendOnFailure { onError(message()) } }.flowOn(Dispatchers.IO) + private suspend fun saveLocalLoginInfo(data: LoginResponse) { + tokenPreferenceDataSource.updateAccessToken(data.tokenResponse.accessToken) + tokenPreferenceDataSource.updateRefreshToken(data.tokenResponse.refreshToken) + userPreferenceDataSource.updateUserId(data.userId) + userPreferenceDataSource.updateUserRole(data.role) + } + companion object { private const val AUTHORIZATION_FORMAT = "Bearer %s" } diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt index a1f67b27..9bc2e5ff 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt @@ -8,11 +8,14 @@ import com.skydoves.sandwich.suspendOnError import com.skydoves.sandwich.suspendOnException import com.withpeace.withpeace.core.data.mapper.toDomain import com.withpeace.withpeace.core.data.util.convertToFile -import com.withpeace.withpeace.core.datastore.dataStore.TokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.user.UserPreferenceDataSource +import com.withpeace.withpeace.core.domain.model.SignUpInfo import com.withpeace.withpeace.core.domain.model.WithPeaceError import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile import com.withpeace.withpeace.core.domain.model.profile.Nickname import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo +import com.withpeace.withpeace.core.domain.model.role.Role import com.withpeace.withpeace.core.domain.repository.UserRepository import com.withpeace.withpeace.core.network.di.common.getErrorBody import com.withpeace.withpeace.core.network.di.request.NicknameRequest @@ -31,6 +34,7 @@ class DefaultUserRepository @Inject constructor( @ApplicationContext private val context: Context, private val userService: UserService, private val tokenPreferenceDataSource: TokenPreferenceDataSource, + private val userPreferenceDataSource: UserPreferenceDataSource, ) : UserRepository { override fun getProfile( onError: suspend (WithPeaceError) -> Unit, @@ -49,12 +53,38 @@ class DefaultUserRepository @Inject constructor( } } - override fun registerProfile( - nickname: String, - profileImage: String, - onError: (WithPeaceError) -> Unit, - ): Flow { - TODO("Not yet implemented") + override suspend fun signUp( + signUpInfo: SignUpInfo, + onError: suspend (WithPeaceError) -> Unit, + ): Flow = flow { + val nicknameRequestBody = + signUpInfo.nickname.toRequestBody("text/plain".toMediaTypeOrNull()) + val request = + if (signUpInfo.profileImage.isNullOrEmpty()) { + userService.signUp( + nicknameRequestBody, + ) + } else { + val profileImagePart = getImagePart(signUpInfo.profileImage!!) + userService.signUp(nicknameRequestBody, profileImagePart) + } + + request.suspendMapSuccess { + val data = this.data + userPreferenceDataSource.updateUserRole(Role.USER.name) + tokenPreferenceDataSource.updateAccessToken(data.accessToken) + tokenPreferenceDataSource.updateRefreshToken(data.refreshToken) + emit(Unit) + }.suspendOnError { + if (statusCode.code == 401) { + onError(WithPeaceError.UnAuthorized()) + } else { + val errorBody = errorBody?.getErrorBody() + onError(WithPeaceError.GeneralError(errorBody?.code, errorBody?.message)) + } + }.suspendOnException { + onError(WithPeaceError.GeneralError(message = messageOrNull)) + } } override fun updateProfile( @@ -134,9 +164,12 @@ class DefaultUserRepository @Inject constructor( } } + + override fun logout(onError: suspend (WithPeaceError) -> Unit): Flow = flow { userService.logout().suspendMapSuccess { tokenPreferenceDataSource.removeAll() + userPreferenceDataSource.removeAll() emit(Unit) }.suspendOnError { if (statusCode.code == 401) { diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/DefaultTokenPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/DefaultTokenPreferenceDataSource.kt similarity index 95% rename from core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/DefaultTokenPreferenceDataSource.kt rename to core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/DefaultTokenPreferenceDataSource.kt index 5461fdd4..2d382b5b 100644 --- a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/DefaultTokenPreferenceDataSource.kt +++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/DefaultTokenPreferenceDataSource.kt @@ -1,4 +1,4 @@ -package com.withpeace.withpeace.core.datastore.dataStore +package com.withpeace.withpeace.core.datastore.dataStore.token import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/TokenPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/TokenPreferenceDataSource.kt similarity index 82% rename from core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/TokenPreferenceDataSource.kt rename to core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/TokenPreferenceDataSource.kt index 1bda640a..a764d6b8 100644 --- a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/TokenPreferenceDataSource.kt +++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/TokenPreferenceDataSource.kt @@ -1,4 +1,4 @@ -package com.withpeace.withpeace.core.datastore.dataStore +package com.withpeace.withpeace.core.datastore.dataStore.token import kotlinx.coroutines.flow.Flow diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/DefaultUserPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/DefaultUserPreferenceDataSource.kt new file mode 100644 index 00000000..0f6ea29b --- /dev/null +++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/DefaultUserPreferenceDataSource.kt @@ -0,0 +1,45 @@ +package com.withpeace.withpeace.core.datastore.dataStore.user + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Named + +class DefaultUserPreferenceDataSource @Inject constructor( + @Named("user") private val dataStore: DataStore, +) : UserPreferenceDataSource { + override val userId: Flow = dataStore.data.map { preferences -> + preferences[USER_ID] + } + override val userRole: Flow = dataStore.data.map { preferences -> + preferences[USER_ROLE] + } + + override suspend fun updateUserId(userId: Long) { + dataStore.edit { preferences -> + preferences[USER_ID] = userId + } + } + + override suspend fun updateUserRole(userRole: String) { + dataStore.edit { preferences -> + preferences[USER_ROLE] = userRole + } + } + + override suspend fun removeAll() { + dataStore.edit { preferences -> + preferences.clear() + } + } + + companion object { + private val USER_ID = longPreferencesKey("USER_ID") + private val USER_ROLE = stringPreferencesKey("USER_ROLE") + } +} \ No newline at end of file diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/UserPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/UserPreferenceDataSource.kt new file mode 100644 index 00000000..6e68a496 --- /dev/null +++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/UserPreferenceDataSource.kt @@ -0,0 +1,15 @@ +package com.withpeace.withpeace.core.datastore.dataStore.user + +import kotlinx.coroutines.flow.Flow + +interface UserPreferenceDataSource { + + val userId: Flow + + val userRole: Flow + + suspend fun updateUserId(userId: Long) + + suspend fun updateUserRole(userRole: String) + suspend fun removeAll() +} \ No newline at end of file diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/DataStoreModule.kt index 8215224f..ae814fcf 100644 --- a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/DataStoreModule.kt @@ -17,8 +17,10 @@ import javax.inject.Singleton object DataStoreModule { private const val AUTH_DATASTORE_NAME = "AUTH_PREFERENCES" + private const val USER_DATASTORE_NAME = "USER_PREFERENCES" private val Context.authDataStore: DataStore by preferencesDataStore(name = AUTH_DATASTORE_NAME) + private val Context.userDataStore: DataStore by preferencesDataStore(name = USER_DATASTORE_NAME) @Provides @Singleton @@ -26,4 +28,11 @@ object DataStoreModule { fun providesTokenDataStore( @ApplicationContext context: Context, ): DataStore = context.authDataStore + + @Provides + @Singleton + @Named("user") + fun providesUserDataStore( + @ApplicationContext context: Context, + ): DataStore = context.userDataStore } \ No newline at end of file diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/PreferenceDataSourceModule.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/PreferenceDataSourceModule.kt index e0beaa82..fc1d0ef2 100644 --- a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/PreferenceDataSourceModule.kt +++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/PreferenceDataSourceModule.kt @@ -1,7 +1,9 @@ package com.withpeace.withpeace.core.datastore.di -import com.withpeace.withpeace.core.datastore.dataStore.DefaultTokenPreferenceDataSource -import com.withpeace.withpeace.core.datastore.dataStore.TokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.token.DefaultTokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.user.DefaultUserPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.user.UserPreferenceDataSource import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +19,10 @@ interface PreferenceDataSourceModule { fun bindsTokenPreferenceDataSource( defaultTokenPreferenceDataSource: DefaultTokenPreferenceDataSource, ): TokenPreferenceDataSource + + @Binds + @Singleton + fun bindsUserPreferenceDataSource( + defaultUserPreferenceDataSource: DefaultUserPreferenceDataSource, + ): UserPreferenceDataSource } \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/SignUpInfo.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/SignUpInfo.kt new file mode 100644 index 00000000..3269c107 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/SignUpInfo.kt @@ -0,0 +1,6 @@ +package com.withpeace.withpeace.core.domain.model + +data class SignUpInfo( + val nickname: String, + val profileImage: String?, +) diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/role/Role.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/role/Role.kt new file mode 100644 index 00000000..1fc76939 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/role/Role.kt @@ -0,0 +1,5 @@ +package com.withpeace.withpeace.core.domain.model.role + +enum class Role { + GUEST, USER, UNKNOWN +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/TokenRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/TokenRepository.kt index e13b3ceb..4beb4e29 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/TokenRepository.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/TokenRepository.kt @@ -1,19 +1,13 @@ package com.withpeace.withpeace.core.domain.repository +import com.withpeace.withpeace.core.domain.model.role.Role import kotlinx.coroutines.flow.Flow interface TokenRepository { - suspend fun isLogin(): Boolean - suspend fun signUp( - email: String, - nickname: String, - onError: (String) -> Unit, - ): Flow - fun getTokenByGoogle( idToken: String, onError: (String) -> Unit, - ): Flow + ): Flow } diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt index 692ce52c..f34131b4 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt @@ -1,5 +1,6 @@ package com.withpeace.withpeace.core.domain.repository +import com.withpeace.withpeace.core.domain.model.SignUpInfo import com.withpeace.withpeace.core.domain.model.WithPeaceError import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile import com.withpeace.withpeace.core.domain.model.profile.Nickname @@ -8,10 +9,6 @@ import kotlinx.coroutines.flow.Flow interface UserRepository { fun getProfile(onError: suspend (WithPeaceError) -> Unit): Flow - fun registerProfile( - nickname: String, profileImage: String, - onError: (WithPeaceError) -> Unit, - ): Flow fun updateProfileImage( profileImage: String, @@ -34,4 +31,8 @@ interface UserRepository { ): Flow fun logout(onError: suspend (WithPeaceError) -> Unit): Flow + suspend fun signUp( + signUpInfo: SignUpInfo, + onError: suspend (WithPeaceError) -> Unit + ): Flow } diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCase.kt index d1cb2fc6..94b03e6f 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCase.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCase.kt @@ -1,7 +1,6 @@ package com.withpeace.withpeace.core.domain.usecase import com.withpeace.withpeace.core.domain.repository.TokenRepository -import kotlinx.coroutines.flow.first import javax.inject.Inject class IsLoginUseCase @Inject constructor( diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SignUpUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SignUpUseCase.kt index 5bacaedf..8bcd21ac 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SignUpUseCase.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SignUpUseCase.kt @@ -1,18 +1,19 @@ package com.withpeace.withpeace.core.domain.usecase +import com.withpeace.withpeace.core.domain.model.SignUpInfo +import com.withpeace.withpeace.core.domain.model.WithPeaceError import com.withpeace.withpeace.core.domain.repository.TokenRepository +import com.withpeace.withpeace.core.domain.repository.UserRepository import javax.inject.Inject class SignUpUseCase @Inject constructor( - private val tokenRepository: TokenRepository, + private val userRepository: UserRepository, ) { suspend operator fun invoke( - email: String, - nickname: String, - onError: (String) -> Unit, - ) = tokenRepository.signUp( - email = email, - nickname = nickname, + signUpInfo: SignUpInfo, + onError: suspend (WithPeaceError) -> Unit, + ) = userRepository.signUp( + signUpInfo, onError = onError, ) } diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCaseTest.kt index 3187c471..3b8d1f72 100644 --- a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCaseTest.kt +++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCaseTest.kt @@ -4,7 +4,6 @@ import com.google.common.truth.Truth.assertThat import com.withpeace.withpeace.core.domain.repository.TokenRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/SignUpUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/SignUpUseCaseTest.kt index bc1392fb..9c142b3e 100644 --- a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/SignUpUseCaseTest.kt +++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/SignUpUseCaseTest.kt @@ -1,6 +1,9 @@ package com.withpeace.withpeace.core.domain.usecase +import com.withpeace.withpeace.core.domain.model.SignUpInfo +import com.withpeace.withpeace.core.domain.model.WithPeaceError import com.withpeace.withpeace.core.domain.repository.TokenRepository +import com.withpeace.withpeace.core.domain.repository.UserRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -11,24 +14,26 @@ import org.junit.Test class SignUpUseCaseTest { private lateinit var signUpUseCase: SignUpUseCase - private val tokenRepository: TokenRepository = mockk(relaxed = true) + private val userRepository: UserRepository = mockk(relaxed = true) - private fun initialize() = SignUpUseCase(tokenRepository) + private fun initialize() = SignUpUseCase(userRepository) @Test fun `회원가입에 성공하면, 성공응답을 반환한다`() = runTest { // given val onSuccess = mockk<() -> Unit>(relaxed = true) coEvery { - tokenRepository.signUp( - "Email", - "nickname", + userRepository.signUp( + SignUpInfo( + "Email", + "nickname", + ), onError = any(), ) } returns flow { onSuccess.invoke() } signUpUseCase = initialize() // when - signUpUseCase("Email", "nickname", {}).collect() + signUpUseCase(SignUpInfo("Email", "nickname"), {}).collect() // then coVerify { onSuccess.invoke() } } @@ -36,18 +41,26 @@ class SignUpUseCaseTest { @Test fun `회원가입에 실패하면, 메세지가 담긴 실패응답을 반환한다`() = runTest { // given - val errorMock = mockk<(String) -> Unit>(relaxed = true) + val errorMock = mockk<(WithPeaceError) -> Unit>(relaxed = true) coEvery { - tokenRepository.signUp( - "Email", - "nickname", + userRepository.signUp( + SignUpInfo( + "Email", + "nickname", + ), onError = errorMock, ) - } returns flow { errorMock("test") } + } returns flow { errorMock(WithPeaceError.UnAuthorized()) } signUpUseCase = initialize() // when - signUpUseCase("Email", "nickname", onError = errorMock).collect() + signUpUseCase( + SignUpInfo( + "Email", + "nickname", + ), + onError = errorMock, + ).collect() // then - coVerify { errorMock("test") } + coVerify { errorMock(WithPeaceError.UnAuthorized()) } } } diff --git a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt index c0b97c8f..98fc3cfd 100644 --- a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt +++ b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt @@ -2,7 +2,7 @@ package com.withpeace.withpeace.core.interceptor import com.skydoves.sandwich.suspendMapSuccess import com.skydoves.sandwich.suspendOnError -import com.withpeace.withpeace.core.datastore.dataStore.TokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource import com.withpeace.withpeace.core.network.di.response.TokenResponse import com.withpeace.withpeace.core.network.di.service.AuthService import kotlinx.coroutines.flow.firstOrNull diff --git a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt index 3904f03d..b75a642a 100644 --- a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt +++ b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt @@ -1,6 +1,6 @@ package com.withpeace.withpeace.core.interceptor -import com.withpeace.withpeace.core.datastore.dataStore.TokenPreferenceDataSource +import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource import com.withpeace.withpeace.core.network.di.service.AuthService import dagger.Module import dagger.Provides diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/SignUpRequest.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/SignUpRequest.kt index d69487fb..ac9248fd 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/SignUpRequest.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/SignUpRequest.kt @@ -1,10 +1,3 @@ package com.withpeace.withpeace.core.network.di.request import kotlinx.serialization.Serializable - -@Serializable -data class SignUpRequest( - val email: String, - val nickname: String, - val deviceToken: String?, -) \ No newline at end of file diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/LoginResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/LoginResponse.kt index f5518b69..e4083e42 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/LoginResponse.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/LoginResponse.kt @@ -8,4 +8,5 @@ data class LoginResponse( @SerialName("jwtTokenDto") val tokenResponse: TokenResponse, val role: String, + val userId: Long, ) diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/AuthService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/AuthService.kt index c1400765..f72871b2 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/AuthService.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/AuthService.kt @@ -1,13 +1,15 @@ package com.withpeace.withpeace.core.network.di.service import com.skydoves.sandwich.ApiResponse -import com.withpeace.withpeace.core.network.di.request.SignUpRequest import com.withpeace.withpeace.core.network.di.response.BaseResponse import com.withpeace.withpeace.core.network.di.response.LoginResponse import com.withpeace.withpeace.core.network.di.response.TokenResponse -import retrofit2.http.Body +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.http.Header +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part interface AuthService { @@ -22,9 +24,4 @@ interface AuthService { @POST("/api/v1/auth/logout") suspend fun logout(): ApiResponse> - - @POST("/api/v1/auth/register") - suspend fun signUp( - @Body signUpRequest: SignUpRequest, - ): ApiResponse> } diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt index 74a716c0..3c844a4f 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt @@ -5,6 +5,7 @@ import com.withpeace.withpeace.core.network.di.request.NicknameRequest import com.withpeace.withpeace.core.network.di.response.BaseResponse import com.withpeace.withpeace.core.network.di.response.ChangedProfileResponse import com.withpeace.withpeace.core.network.di.response.ProfileResponse +import com.withpeace.withpeace.core.network.di.response.TokenResponse import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.http.Body @@ -39,4 +40,17 @@ interface UserService { @POST("/api/v1/auth/logout") suspend fun logout(): ApiResponse> + + @Multipart + @POST("/api/v1/auth/register") + suspend fun signUp( + @Part("nickname") nickname: RequestBody, + @Part imageFile: MultipartBody.Part, + ): ApiResponse> + + @Multipart + @POST("/api/v1/auth/register") + suspend fun signUp( + @Part("nickname") nickname: RequestBody, + ): ApiResponse> } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 79e8c0e2..83081799 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -9,5 +9,9 @@ android { } dependencies { + implementation(project(":core:permission")) implementation(project(":core:domain")) + implementation(project(":core:designsystem")) + implementation(libs.skydoves.landscapist.glide) + implementation(libs.skydoves.landscapist.bom) } diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/NicknameEditor.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/NicknameEditor.kt new file mode 100644 index 00000000..3b6cea66 --- /dev/null +++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/NicknameEditor.kt @@ -0,0 +1,107 @@ +package com.withpeace.withpeace.core.ui.profile + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme +import com.withpeace.withpeace.core.ui.R +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NickNameEditor( + modifier: Modifier = Modifier, + nickname: String, + isChanged: Boolean, + nicknameValidStatus: ProfileNicknameValidUiState, + onNickNameChanged: (String) -> Unit, + onKeyBoardTimerEnd: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(nickname) { + delay(1.seconds) + onKeyBoardTimerEnd() + } + + Column( + modifier = modifier + .width(140.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BasicTextField( + value = nickname, + onValueChange = { + onNickNameChanged(it) + }, + modifier = modifier.fillMaxWidth(), + enabled = true, + textStyle = WithpeaceTheme.typography.body.copy(textAlign = TextAlign.Center), + singleLine = true, + maxLines = 1, + ) { + TextFieldDefaults.DecorationBox( + value = nickname, + innerTextField = it, + enabled = true, + singleLine = false, + visualTransformation = VisualTransformation.None, + placeholder = { + Text( + modifier = modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(R.string.enter_nickname), + style = WithpeaceTheme.typography.body, + color = WithpeaceTheme.colors.SystemGray2, + ) + }, + interactionSource = interactionSource, + contentPadding = PaddingValues(0.dp), + colors = TextFieldDefaults.colors( + disabledTextColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ), + ) + } + Divider( + color = if (nicknameValidStatus is ProfileNicknameValidUiState.Valid || isChanged.not()) WithpeaceTheme.colors.SystemBlack + else WithpeaceTheme.colors.SystemError, + modifier = modifier + .width(140.dp) + .height(1.dp), + ) + } + if (nicknameValidStatus !is ProfileNicknameValidUiState.Valid && isChanged) { + Text( + text = if (nicknameValidStatus is ProfileNicknameValidUiState.InValidDuplicated) stringResource( + R.string.nickname_duplicated, + ) else stringResource(id = R.string.nickname_policy), + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SystemError, + modifier = modifier.padding(top = 4.dp), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileEditor.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileEditor.kt new file mode 100644 index 00000000..b65306ad --- /dev/null +++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileEditor.kt @@ -0,0 +1,82 @@ +package com.withpeace.withpeace.core.ui.profile + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.skydoves.landscapist.glide.GlideImage +import com.withpeace.withpeace.core.permission.ImagePermissionHelper +import com.withpeace.withpeace.core.ui.R + +@Composable +fun ProfileImageEditor( + profileImage: String?, + modifier: Modifier, + onNavigateToGallery: () -> Unit, + contentDescription: String +) { + var showDialog by rememberSaveable { mutableStateOf(false) } + val context = LocalContext.current + val imagePermissionHelper = remember { ImagePermissionHelper(context) } + val launcher = imagePermissionHelper.getImageLauncher( + onPermissionGranted = onNavigateToGallery, + onPermissionDenied = { showDialog = true }, + ) + if (showDialog) { + imagePermissionHelper.ImagePermissionDialog { showDialog = false } + } + val imageModifier = modifier + .size(120.dp) + .clip(CircleShape) + Row( + modifier = modifier.wrapContentSize(Alignment.Center), + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier.clickable { + imagePermissionHelper.onCheckSelfImagePermission( + onPermissionGranted = onNavigateToGallery, + onPermissionDenied = { + imagePermissionHelper.requestPermissionDialog(launcher) + }, + ) + }, + ) { + GlideImage( + modifier = imageModifier, + imageModel = { profileImage }, + failure = { + Image( + painterResource(id = R.drawable.ic_default_profile), + modifier = imageModifier, + contentDescription = contentDescription, + ) + }, + ) + Image( + modifier = modifier + .align(Alignment.BottomEnd) + .padding(bottom = 6.dp, end = 6.dp), + painter = painterResource(id = R.drawable.ic_editor_pencil), + contentDescription = contentDescription, + ) + } + } +} \ No newline at end of file diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileNicknameValidUiState.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileNicknameValidUiState.kt similarity index 82% rename from feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileNicknameValidUiState.kt rename to core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileNicknameValidUiState.kt index c594e384..97cd72cc 100644 --- a/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileNicknameValidUiState.kt +++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileNicknameValidUiState.kt @@ -1,4 +1,4 @@ -package com.app.profileeditor.uistate +package com.withpeace.withpeace.core.ui.profile sealed interface ProfileNicknameValidUiState { data object Valid : ProfileNicknameValidUiState diff --git a/feature/profileeditor/src/main/res/drawable/ic_default_profile.xml b/core/ui/src/main/res/drawable/ic_default_profile.xml similarity index 100% rename from feature/profileeditor/src/main/res/drawable/ic_default_profile.xml rename to core/ui/src/main/res/drawable/ic_default_profile.xml diff --git a/feature/profileeditor/src/main/res/drawable/ic_editor_pencil.xml b/core/ui/src/main/res/drawable/ic_editor_pencil.xml similarity index 100% rename from feature/profileeditor/src/main/res/drawable/ic_editor_pencil.xml rename to core/ui/src/main/res/drawable/ic_editor_pencil.xml diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index c6eb36af..3c1c486f 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -1,15 +1,22 @@ + 자유 정보 "질문" "생활" "취미" "경제" + + %1$d초 전 %1$d분 전 %1$d시간 전 %1$d일 전 %1$d년 전 + + 닉네임은 2~10자의 한글, 영문만 가능합니다. + 닉네임을 입력하세요 + 중복된 닉네임입니다. diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginScreen.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginScreen.kt index ac80036f..72faaf23 100644 --- a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginScreen.kt +++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginScreen.kt @@ -39,6 +39,8 @@ import kotlinx.coroutines.launch fun LoginRoute( viewModel: LoginViewModel = hiltViewModel(), onShowSnackBar: (message: String) -> Unit, + onSignUpNeeded: () -> Unit, + onLoginSuccess: () -> Unit, ) { LoginScreen( onGoogleLogin = viewModel::googleLogin, @@ -55,11 +57,16 @@ fun LoginRoute( onShowSnackBar("회원가입에 실패하였습니다!") } - LoginUiEvent.LoginFail -> { + is LoginUiEvent.SignUpNeeded -> { + onSignUpNeeded() + } + + is LoginUiEvent.LoginFail -> { onShowSnackBar("서버와의 로그인 인증에 실패하였습니다!") } - LoginUiEvent.LoginSuccess -> { - onShowSnackBar("로그인에 성공하였습니다!") + + is LoginUiEvent.LoginSuccess -> { + onLoginSuccess() } } } @@ -137,10 +144,12 @@ fun LoginScreen( contentDescription = stringResource(R.string.image_google_logo), ) Text( - modifier = Modifier.align(Alignment.Center).padding(vertical = 18.dp), + modifier = Modifier + .align(Alignment.Center) + .padding(vertical = 18.dp), color = WithpeaceTheme.colors.SystemBlack, style = WithpeaceTheme.typography.notoSans.merge( - TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) + TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), ), text = stringResource(R.string.login_to_google), ) diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginUiEvent.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginUiEvent.kt index 22e189a2..1e0adb75 100644 --- a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginUiEvent.kt +++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginUiEvent.kt @@ -5,6 +5,8 @@ sealed interface LoginUiEvent { data object LoginFail : LoginUiEvent + data object SignUpNeeded : LoginUiEvent + data object SignUpSuccess : LoginUiEvent data class SignUpFail(val message: String?) : LoginUiEvent diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginViewModel.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginViewModel.kt index 33c48b7c..ffde79fe 100644 --- a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginViewModel.kt +++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginViewModel.kt @@ -2,8 +2,8 @@ package com.withpeace.withpeace.feature.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.withpeace.withpeace.core.domain.model.role.Role import com.withpeace.withpeace.core.domain.usecase.GoogleLoginUseCase -import com.withpeace.withpeace.core.domain.usecase.SignUpUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -13,7 +13,6 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val googleLoginUseCase: GoogleLoginUseCase, - private val signUpUseCase: SignUpUseCase, ) : ViewModel() { private val _loginUiEvent: Channel = Channel() @@ -24,20 +23,16 @@ class LoginViewModel @Inject constructor( googleLoginUseCase( idToken = idToken, onError = { launch { _loginUiEvent.send(LoginUiEvent.LoginFail) } }, - ).collect { launch { _loginUiEvent.send(LoginUiEvent.LoginSuccess) } } - } - } - - fun signUp( - email: String, - nickname: String, - ) { - viewModelScope.launch { - signUpUseCase( - email = email, - nickname = nickname, - onError = { launch { _loginUiEvent.send(LoginUiEvent.SignUpFail(it)) } }, - ).collect { _loginUiEvent.send(LoginUiEvent.SignUpSuccess) } + ).collect { + launch { + when (it) { + Role.USER -> _loginUiEvent.send(LoginUiEvent.LoginSuccess) + Role.GUEST -> _loginUiEvent.send(LoginUiEvent.SignUpNeeded) + Role.UNKNOWN -> _loginUiEvent.send(LoginUiEvent.LoginFail) + // UNKNOWN은 서버에서 내려주는 역할이 string이므로 휴먼에러 방지를 위함 + } + } + } } } } diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/navigation/LoginNavigation.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/navigation/LoginNavigation.kt index 499cd550..db308176 100644 --- a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/navigation/LoginNavigation.kt +++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/navigation/LoginNavigation.kt @@ -14,8 +14,14 @@ fun NavController.navigateLogin(navOptions: NavOptions? = null) { fun NavGraphBuilder.loginNavGraph( onShowSnackBar: (message: String) -> Unit, + onSignUpNeeded: () -> Unit, + onLoginSuccess: () -> Unit, ) { composable(route = LOGIN_ROUTE) { - LoginRoute(onShowSnackBar = onShowSnackBar) + LoginRoute( + onShowSnackBar = onShowSnackBar, + onSignUpNeeded = onSignUpNeeded, + onLoginSuccess = onLoginSuccess, + ) } } \ No newline at end of file diff --git a/feature/login/src/test/java/com/withpeace/withpeace/feature/login/LoginViewModelTest.kt b/feature/login/src/test/java/com/withpeace/withpeace/feature/login/LoginViewModelTest.kt index 2cce344b..654aa061 100644 --- a/feature/login/src/test/java/com/withpeace/withpeace/feature/login/LoginViewModelTest.kt +++ b/feature/login/src/test/java/com/withpeace/withpeace/feature/login/LoginViewModelTest.kt @@ -2,8 +2,8 @@ package com.withpeace.withpeace.feature.login import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import com.withpeace.withpeace.core.domain.model.role.Role import com.withpeace.withpeace.core.domain.usecase.GoogleLoginUseCase -import com.withpeace.withpeace.core.domain.usecase.SignUpUseCase import com.withpeace.withpeace.core.testing.MainDispatcherRule import io.mockk.coEvery import io.mockk.mockk @@ -20,14 +20,13 @@ class LoginViewModelTest { private lateinit var viewModel: LoginViewModel private val googleLoginUseCase: GoogleLoginUseCase = mockk() - private val signUpUseCase: SignUpUseCase = mockk(relaxed = true) private fun initialize(): LoginViewModel { - return LoginViewModel(googleLoginUseCase, signUpUseCase) + return LoginViewModel(googleLoginUseCase) } @Test - fun `구글 로그인에 성공하면 로그인 성공 이벤트를 발생한다`() = runTest { + fun `구글 로그인에 성공하고 권한이 유저이면 로그인 성공 이벤트를 발생한다`() = runTest { // given coEvery { googleLoginUseCase( @@ -35,7 +34,7 @@ class LoginViewModelTest { onError = any(), ) } returns flow { - emit(Unit) + emit(Role.USER) } viewModel = initialize() @@ -48,67 +47,64 @@ class LoginViewModelTest { } @Test - fun `구글 로그인에 실패하면 로그인 실패 이벤트를 발생한다`() = runTest { + fun `구글 로그인에 성공하고 권한이 게스트이면 회원가입 필요 이벤트를 발생한다`() = runTest { // given - val onFailSlot = slot<(String) -> Unit>() coEvery { googleLoginUseCase( "test", - onError = capture(onFailSlot), + onError = any(), ) - } returns flow { onFailSlot.captured("test") } - + } returns flow { + emit(Role.GUEST) + } viewModel = initialize() + // when & then viewModel.loginUiEvent.test { viewModel.googleLogin("test") val actual = awaitItem() - assertThat(actual).isEqualTo(LoginUiEvent.LoginFail) + assertThat(actual).isEqualTo(LoginUiEvent.SignUpNeeded) } } @Test - fun `회원가입 실패하면 회원가입 실패 이벤트를 발생한다`() = runTest { + fun `구글 로그인에 성공하고 권한이 알 수 없으면 로그인 실패 이벤트를 발생한다`() = runTest { // given - val onErrorSlot = slot<(String) -> Unit>() coEvery { - signUpUseCase( - email = "abc", - nickname = "abc", - onError = capture(onErrorSlot), + googleLoginUseCase( + "test", + onError = any(), ) } returns flow { - onErrorSlot.captured("message") + emit(Role.UNKNOWN) } viewModel = initialize() // when & then viewModel.loginUiEvent.test { - viewModel.signUp("abc", "abc") + viewModel.googleLogin("test") val actual = awaitItem() - assertThat(actual).isEqualTo(LoginUiEvent.SignUpFail("message")) + assertThat(actual).isEqualTo(LoginUiEvent.LoginFail) } } @Test - fun `회원가입 성공하면 회원가입 성공 이벤트를 발생한다`() = runTest { + fun `구글 로그인에 실패하면 로그인 실패 이벤트를 발생한다`() = runTest { // given + val onFailSlot = slot<(String) -> Unit>() coEvery { - signUpUseCase( - email = "abc", - nickname = "abc", - onError = any(), + googleLoginUseCase( + "test", + onError = capture(onFailSlot), ) - } returns flow { - emit(Unit) - } - viewModel = initialize() + } returns flow { onFailSlot.captured("test") } + viewModel = initialize() // when & then viewModel.loginUiEvent.test { - viewModel.signUp("abc", "abc") + viewModel.googleLogin("test") val actual = awaitItem() - assertThat(actual).isEqualTo(LoginUiEvent.SignUpSuccess) + assertThat(actual).isEqualTo(LoginUiEvent.LoginFail) } } } diff --git a/feature/profileeditor/build.gradle.kts b/feature/profileeditor/build.gradle.kts index 81822e00..914a6446 100644 --- a/feature/profileeditor/build.gradle.kts +++ b/feature/profileeditor/build.gradle.kts @@ -7,7 +7,6 @@ android { } dependencies { - implementation(project(":core:permission")) - implementation(libs.skydoves.landscapist.glide) - implementation(libs.skydoves.landscapist.bom) + + implementation(project(":core:ui")) } \ No newline at end of file diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorScreen.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorScreen.kt index 53b150df..3869567a 100644 --- a/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorScreen.kt +++ b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorScreen.kt @@ -1,12 +1,8 @@ package com.app.profileeditor -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -15,50 +11,36 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextFieldDefaults 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.app.profileeditor.uistate.ProfileEditUiEvent -import com.app.profileeditor.uistate.ProfileNicknameValidUiState import com.app.profileeditor.uistate.ProfileUiModel -import com.skydoves.landscapist.glide.GlideImage import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme import com.withpeace.withpeace.core.designsystem.ui.WithPeaceBackButtonTopAppBar -import com.withpeace.withpeace.core.permission.ImagePermissionHelper +import com.withpeace.withpeace.core.ui.profile.NickNameEditor +import com.withpeace.withpeace.core.ui.profile.ProfileImageEditor +import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState import com.withpeace.withpeace.feature.profileeditor.R -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.seconds @Composable fun ProfileEditorRoute( @@ -167,19 +149,20 @@ fun ProfileEditorScreen( }, ) Spacer(modifier = modifier.height(16.dp)) - ProfileImage( + ProfileImageEditor( profileImage = profileInfo.profileImage, modifier = modifier, onNavigateToGallery = onNavigateToGallery, + contentDescription = stringResource(id = R.string.edit_profile), ) Spacer(modifier = modifier.height(24.dp)) Text( - text = stringResource(R.string.nickname_policy), + text = stringResource(com.withpeace.withpeace.core.ui.R.string.nickname_policy), style = WithpeaceTheme.typography.caption, color = WithpeaceTheme.colors.SystemGray1, ) Spacer(modifier = modifier.height(16.dp)) - NickNameTextField( + NickNameEditor( nickname = profileInfo.nickname, onNickNameChanged = { onNickNameChanged(it) @@ -194,141 +177,6 @@ fun ProfileEditorScreen( } } -@Composable -private fun ProfileImage( - profileImage: String?, - modifier: Modifier, - onNavigateToGallery: () -> Unit, -) { - var showDialog by rememberSaveable { mutableStateOf(false) } - val context = LocalContext.current - val imagePermissionHelper = remember { ImagePermissionHelper(context) } - val launcher = imagePermissionHelper.getImageLauncher( - onPermissionGranted = onNavigateToGallery, - onPermissionDenied = { showDialog = true }, - ) - if (showDialog) { - imagePermissionHelper.ImagePermissionDialog { showDialog = false } - } - val imageModifier = modifier - .size(120.dp) - .clip(CircleShape) - Row( - modifier = modifier.wrapContentSize(Alignment.Center), - horizontalArrangement = Arrangement.Center, - ) { - Box( - modifier.clickable { - imagePermissionHelper.onCheckSelfImagePermission( - onPermissionGranted = onNavigateToGallery, - onPermissionDenied = { - imagePermissionHelper.requestPermissionDialog(launcher) - }, - ) - }, - ) { - GlideImage( - modifier = imageModifier, - imageModel = { profileImage }, - failure = { - Image( - painterResource(id = R.drawable.ic_default_profile), - modifier = imageModifier, - contentDescription = "", - ) - }, - ) - Image( - modifier = modifier - .align(Alignment.BottomEnd) - .padding(bottom = 6.dp, end = 6.dp), - painter = painterResource(id = R.drawable.ic_editor_pencil), - contentDescription = stringResource(id = R.string.edit_profile), - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun NickNameTextField( - modifier: Modifier = Modifier, - nickname: String, - isChanged: Boolean, - nicknameValidStatus: ProfileNicknameValidUiState, - onNickNameChanged: (String) -> Unit, - onKeyBoardTimerEnd: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - LaunchedEffect(nickname) { - delay(1.seconds) - onKeyBoardTimerEnd() - } - - Column( - modifier = modifier - .width(140.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - BasicTextField( - value = nickname, - onValueChange = { - onNickNameChanged(it) - }, - modifier = modifier.fillMaxWidth(), - enabled = true, - textStyle = WithpeaceTheme.typography.body.copy(textAlign = TextAlign.Center), - singleLine = true, - maxLines = 1, - ) { - TextFieldDefaults.DecorationBox( - value = nickname, - innerTextField = it, - enabled = true, - singleLine = false, - visualTransformation = VisualTransformation.None, - placeholder = { - Text( - modifier = modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(R.string.enter_nickname), - style = WithpeaceTheme.typography.body, - color = WithpeaceTheme.colors.SystemGray2, - ) - }, - interactionSource = interactionSource, - contentPadding = PaddingValues(0.dp), - colors = TextFieldDefaults.colors( - disabledTextColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedContainerColor = Color.Transparent, - ), - ) - } - Divider( - color = if (nicknameValidStatus is ProfileNicknameValidUiState.Valid || isChanged.not()) WithpeaceTheme.colors.SystemBlack - else WithpeaceTheme.colors.SystemError, - modifier = modifier - .width(140.dp) - .height(1.dp), - ) - } - if (nicknameValidStatus !is ProfileNicknameValidUiState.Valid && isChanged) { - Text( - text = if (nicknameValidStatus is ProfileNicknameValidUiState.InValidDuplicated) stringResource( - R.string.nickname_duplicated, - ) else stringResource(id = R.string.nickname_policy), - style = WithpeaceTheme.typography.caption, - color = WithpeaceTheme.colors.SystemError, - modifier = modifier.padding(top = 4.dp), - ) - } -} - - @Composable private fun EditCompletedButton( onClick: () -> Unit, @@ -452,4 +300,3 @@ fun ProfileEditorPreview() { ) } } -// uiModel 적용, glide 수정, response 값 전달 \ No newline at end of file diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorViewModel.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorViewModel.kt index 070f1415..356a4d34 100644 --- a/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorViewModel.kt +++ b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorViewModel.kt @@ -6,11 +6,11 @@ import androidx.lifecycle.viewModelScope import com.app.profileeditor.navigation.PROFILE_IMAGE_URL_ARGUMENT import com.app.profileeditor.navigation.PROFILE_NICKNAME_ARGUMENT import com.app.profileeditor.uistate.ProfileEditUiEvent -import com.app.profileeditor.uistate.ProfileNicknameValidUiState import com.app.profileeditor.uistate.ProfileUiModel import com.withpeace.withpeace.core.domain.model.WithPeaceError import com.withpeace.withpeace.core.domain.usecase.UpdateProfileUseCase import com.withpeace.withpeace.core.domain.usecase.VerifyNicknameUseCase +import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/feature/profileeditor/src/main/res/values/strings.xml b/feature/profileeditor/src/main/res/values/strings.xml index 357c2965..3d3014d4 100644 --- a/feature/profileeditor/src/main/res/values/strings.xml +++ b/feature/profileeditor/src/main/res/values/strings.xml @@ -1,11 +1,8 @@ 프로필 수정 - 닉네임은 2~10자의 한글, 영문만 가능합니다. 수정 완료 수정사항이 있습니다.\n저장하시겠습니까? 나가기 저장하기 - 닉네임을 입력하세요 - 중복된 닉네임입니다. \ No newline at end of file diff --git a/feature/signup/.gitignore b/feature/signup/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/signup/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/signup/build.gradle.kts b/feature/signup/build.gradle.kts new file mode 100644 index 00000000..791fdec8 --- /dev/null +++ b/feature/signup/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("convention.feature") +} + +android { + namespace = "com.withpeace.withpeace.feature.signup" +} + +dependencies { + implementation(project(":core:permission")) + implementation(libs.skydoves.landscapist.bom) + implementation(libs.skydoves.landscapist.glide) +} \ No newline at end of file diff --git a/feature/signup/consumer-rules.pro b/feature/signup/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/signup/proguard-rules.pro b/feature/signup/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/signup/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/signup/src/androidTest/java/com/withpeace/withpeace/feature/signup/ExampleInstrumentedTest.kt b/feature/signup/src/androidTest/java/com/withpeace/withpeace/feature/signup/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..f9defe41 --- /dev/null +++ b/feature/signup/src/androidTest/java/com/withpeace/withpeace/feature/signup/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.withpeace.withpeace.feature.signup + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.withpeace.withpeace.feature.signup.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/signup/src/main/AndroidManifest.xml b/feature/signup/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/signup/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpMapper.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpMapper.kt new file mode 100644 index 00000000..53df4337 --- /dev/null +++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpMapper.kt @@ -0,0 +1,8 @@ +package com.withpeace.withpeace.feature.signup + +import com.withpeace.withpeace.core.domain.model.SignUpInfo +import com.withpeace.withpeace.feature.signup.uistate.SignUpUiModel + +fun SignUpUiModel.toDomain(): SignUpInfo { + return SignUpInfo(nickname, profileImage) +} \ No newline at end of file diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpScreen.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpScreen.kt new file mode 100644 index 00000000..0086333c --- /dev/null +++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpScreen.kt @@ -0,0 +1,158 @@ +package com.withpeace.withpeace.feature.signup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme +import com.withpeace.withpeace.core.ui.profile.NickNameEditor +import com.withpeace.withpeace.core.ui.profile.ProfileImageEditor +import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState +import com.withpeace.withpeace.feature.signup.uistate.SignUpUiEvent +import com.withpeace.withpeace.feature.signup.uistate.SignUpUiModel + +@Composable +fun SignUpRoute( + viewModel: SignUpViewModel, + onShowSnackBar: (message: String) -> Unit, + onNavigateToGallery: () -> Unit, + onSignUpSuccess: () -> Unit, +) { + val signUpInfo = viewModel.signUpUiModel.collectAsStateWithLifecycle() + val nicknameValidStatus = viewModel.profileNicknameValidUiState.collectAsStateWithLifecycle() + SignUpScreen( + isChanged = signUpInfo.value.nickname.isNotEmpty(), + signUpInfo = signUpInfo.value, + onNavigateToGallery = onNavigateToGallery, + onSignUpClick = viewModel::signUp, + onNickNameChanged = viewModel::onNickNameChanged, + onKeyBoardTimerEnd = viewModel::verifyNickname, + nicknameValidStatus = nicknameValidStatus.value, + ) + LaunchedEffect(viewModel.signUpEvent) { + viewModel.signUpEvent.collect { + when (it) { + SignUpUiEvent.NicknameInValid -> onShowSnackBar("닉네임 등록을 완료해주세요") + SignUpUiEvent.SignUpFail -> onShowSnackBar("서버와 통신 중 오류가 발생했습니다") + SignUpUiEvent.SignUpSuccess -> onSignUpSuccess() + SignUpUiEvent.UnAuthorized -> onShowSnackBar("인가 되지 않은 게정이에요") + SignUpUiEvent.VerifyFail -> onShowSnackBar("서버와 통신 중 오류가 발생했습니다") + } + } + } +} + +@Composable +fun SignUpScreen( + signUpInfo: SignUpUiModel, + isChanged: Boolean, + modifier: Modifier = Modifier, + onNavigateToGallery: () -> Unit, + onSignUpClick: () -> Unit, + onNickNameChanged: (String) -> Unit, + onKeyBoardTimerEnd: () -> Unit, + nicknameValidStatus: ProfileNicknameValidUiState, +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(104.dp)) + Text( + text = stringResource( + R.string.create_profile, + ), + color = WithpeaceTheme.colors.SystemBlack, + style = WithpeaceTheme.typography.title1, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(24.dp)) + ProfileImageEditor( + profileImage = signUpInfo.profileImage, + modifier = modifier, + onNavigateToGallery = { onNavigateToGallery() }, + contentDescription = stringResource(R.string.set_up_profile_image), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.nickname_policy), + color = WithpeaceTheme.colors.SystemGray1, + style = WithpeaceTheme.typography.caption, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + NickNameEditor( + nickname = signUpInfo.nickname, + onNickNameChanged = onNickNameChanged, + isChanged = isChanged, // nickname.isNotEmpty() + modifier = modifier, + nicknameValidStatus = nicknameValidStatus, + onKeyBoardTimerEnd = onKeyBoardTimerEnd, + ) + Spacer(modifier = Modifier.height(72.dp)) + } + SignUpButton(onClick = onSignUpClick) + } +} + +@Composable +private fun SignUpButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = { + onClick() + }, + contentPadding = PaddingValues(0.dp), + modifier = modifier + .padding( + bottom = 40.dp, + end = WithpeaceTheme.padding.BasicHorizontalPadding, + start = WithpeaceTheme.padding.BasicHorizontalPadding, + ) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = WithpeaceTheme.colors.MainPink), + shape = RoundedCornerShape(9.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(R.string.sign_up_completed), + modifier = Modifier.padding(vertical = 18.dp), + color = WithpeaceTheme.colors.SystemWhite, + ) + } +} + +@Preview(widthDp = 400, heightDp = 900, showBackground = true) +@Composable +fun SignUpPreview() { + SignUpScreen( + isChanged = false, + onNavigateToGallery = {}, + onSignUpClick = {}, + onNickNameChanged = { }, + onKeyBoardTimerEnd = {}, + nicknameValidStatus = ProfileNicknameValidUiState.InValidDuplicated, + signUpInfo = SignUpUiModel(nickname = "", profileImage = null), + ) +} \ No newline at end of file diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpViewModel.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpViewModel.kt new file mode 100644 index 00000000..96cd6aa0 --- /dev/null +++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpViewModel.kt @@ -0,0 +1,106 @@ +package com.withpeace.withpeace.feature.signup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.withpeace.withpeace.core.domain.model.WithPeaceError +import com.withpeace.withpeace.core.domain.usecase.SignUpUseCase +import com.withpeace.withpeace.core.domain.usecase.VerifyNicknameUseCase +import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState +import com.withpeace.withpeace.feature.signup.uistate.SignUpUiEvent +import com.withpeace.withpeace.feature.signup.uistate.SignUpUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignUpViewModel @Inject constructor( + private val verifyNicknameUseCase: VerifyNicknameUseCase, + private val signUpUseCase: SignUpUseCase, +) : ViewModel() { + private val _signUpInfo = MutableStateFlow( + SignUpUiModel( + "", null, + ), + ) + val signUpUiModel = _signUpInfo.asStateFlow() + + private val _profileNicknameValidUiState = + MutableStateFlow(ProfileNicknameValidUiState.Valid) + val profileNicknameValidUiState = _profileNicknameValidUiState.asStateFlow() + + private val _signUpEvent = Channel() + val signUpEvent = _signUpEvent.receiveAsFlow() + + fun onNickNameChanged(nickname: String) { + _signUpInfo.update { it.copy(nickname = nickname) } + } + + fun verifyNickname() { + viewModelScope.launch { + if (_signUpInfo.value.nickname.isEmpty()) { + _profileNicknameValidUiState.update { ProfileNicknameValidUiState.Valid } + return@launch + } + verifyNicknameUseCase( + nickname = _signUpInfo.value.nickname, + onError = { error -> + when (error) { + is WithPeaceError.GeneralError -> { + when (error.code) { + 1 -> _profileNicknameValidUiState.update { ProfileNicknameValidUiState.InValidFormat } + 2 -> _profileNicknameValidUiState.update { ProfileNicknameValidUiState.InValidDuplicated } + } + } + + else -> _signUpEvent.send(SignUpUiEvent.VerifyFail) + } + }, + ).collect { + _profileNicknameValidUiState.update { ProfileNicknameValidUiState.Valid } + } + } + } + + fun onImageChanged(imageUri: String?) { + _signUpInfo.update { it.copy(profileImage = imageUri) } + } + + fun signUp() { + viewModelScope.launch { + if (_signUpInfo.value.nickname.isEmpty() || + profileNicknameValidUiState.value !is ProfileNicknameValidUiState.Valid + ) { + _signUpEvent.send(SignUpUiEvent.NicknameInValid) + return@launch + } + viewModelScope.launch { + signUpUseCase( + signUpUiModel.value.toDomain(), + onError = { + when (it) { + is WithPeaceError.GeneralError -> { + when (it.code) { + 40001 -> SignUpUiEvent.NicknameInValid + 40007 -> SignUpUiEvent.NicknameInValid + else -> SignUpUiEvent.SignUpFail + } + } + + is WithPeaceError.UnAuthorized -> { + _signUpEvent.send(SignUpUiEvent.UnAuthorized) + } + } + }, + ).collect { + _signUpEvent.send(SignUpUiEvent.SignUpSuccess) + } + } + } + } +} + diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/navigation/SignUpNavigation.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/navigation/SignUpNavigation.kt new file mode 100644 index 00000000..cdf5712e --- /dev/null +++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/navigation/SignUpNavigation.kt @@ -0,0 +1,37 @@ +package com.withpeace.withpeace.feature.signup.navigation + +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.withpeace.withpeace.feature.signup.SignUpRoute +import com.withpeace.withpeace.feature.signup.SignUpViewModel + +const val SIGN_UP_ROUTE = "signUpRoute" +private const val IMAGE_LIST_ARGUMENT = "image_list_argument" + +fun NavController.navigateSignUp(navOptions: NavOptions? = null) { + navigate(SIGN_UP_ROUTE, navOptions) +} + +fun NavGraphBuilder.signUpNavGraph( + onShowSnackBar: (message: String) -> Unit, + onNavigateToGallery: () -> Unit, + onSignUpSuccess: () -> Unit, +) { + composable(route = SIGN_UP_ROUTE) { entry -> + val selectedImageUri = + entry.savedStateHandle.get>(IMAGE_LIST_ARGUMENT) ?: emptyList() + val viewModel: SignUpViewModel = hiltViewModel() + if (selectedImageUri.isNotEmpty()) { + viewModel.onImageChanged(imageUri = selectedImageUri.firstOrNull() ?: null) + } + SignUpRoute( + onShowSnackBar = onShowSnackBar, + onNavigateToGallery = onNavigateToGallery, + viewModel = viewModel, + onSignUpSuccess = onSignUpSuccess + ) + } +} \ No newline at end of file diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiEvent.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiEvent.kt new file mode 100644 index 00000000..f812f340 --- /dev/null +++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiEvent.kt @@ -0,0 +1,9 @@ +package com.withpeace.withpeace.feature.signup.uistate + +sealed interface SignUpUiEvent { + data object SignUpSuccess : SignUpUiEvent + data object SignUpFail : SignUpUiEvent + data object VerifyFail : SignUpUiEvent + data object UnAuthorized : SignUpUiEvent + data object NicknameInValid : SignUpUiEvent +} \ No newline at end of file diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiModel.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiModel.kt new file mode 100644 index 00000000..4c485617 --- /dev/null +++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiModel.kt @@ -0,0 +1,6 @@ +package com.withpeace.withpeace.feature.signup.uistate + +data class SignUpUiModel( + val nickname: String, + val profileImage: String?, +) diff --git a/feature/signup/src/main/res/drawable/app_logo.png b/feature/signup/src/main/res/drawable/app_logo.png new file mode 100644 index 00000000..544764ca Binary files /dev/null and b/feature/signup/src/main/res/drawable/app_logo.png differ diff --git a/feature/signup/src/main/res/values/strings.xml b/feature/signup/src/main/res/values/strings.xml new file mode 100644 index 00000000..5347d73a --- /dev/null +++ b/feature/signup/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + 프로필을 설정해주세요! + 닉네임은 2~10자의 한글, 영문만 가능합니다. + 닉네임을 입력하세요 + 가입 완료 + 프로필 이미지 설정 + \ No newline at end of file diff --git a/feature/signup/src/test/java/com/withpeace/withpeace/feature/signup/ExampleUnitTest.kt b/feature/signup/src/test/java/com/withpeace/withpeace/feature/signup/ExampleUnitTest.kt new file mode 100644 index 00000000..129e2610 --- /dev/null +++ b/feature/signup/src/test/java/com/withpeace/withpeace/feature/signup/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.withpeace.withpeace.feature.signup + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 95a825af..2a042142 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ include(":core:designsystem") include(":core:interceptor") include(":core:testing") include(":core:ui") +include(":feature:signup") include(":feature:home") include(":feature:postlist") include(":feature:mypage")