diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 17a570a2..6ac709f8 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -44,7 +44,8 @@ dependencies { implementation(projects.featureHomeImpl) implementation(projects.featureHistoryApi) implementation(projects.featureHistoryImpl) - implementation(projects.featureProfileApi) + implementation(projects.featureMyApi) + implementation(projects.featureMyImpl) implementation(projects.featureProfileImpl) implementation(libs.androidx.core.ktx) diff --git a/Prezel/app/src/main/AndroidManifest.xml b/Prezel/app/src/main/AndroidManifest.xml index 880e3bc2..3e52a766 100644 --- a/Prezel/app/src/main/AndroidManifest.xml +++ b/Prezel/app/src/main/AndroidManifest.xml @@ -15,7 +15,8 @@ android:theme="@style/Theme.PrezelSplashScreen"> + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/Prezel/app/src/main/java/com/team/prezel/navigation/TopLevelNavItem.kt b/Prezel/app/src/main/java/com/team/prezel/navigation/TopLevelNavItem.kt index f1a633e2..1b195e46 100644 --- a/Prezel/app/src/main/java/com/team/prezel/navigation/TopLevelNavItem.kt +++ b/Prezel/app/src/main/java/com/team/prezel/navigation/TopLevelNavItem.kt @@ -8,7 +8,7 @@ import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.feature.history.api.HistoryNavKey import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.login.api.LoginNavKey -import com.team.prezel.feature.profile.api.ProfileNavKey +import com.team.prezel.feature.my.api.MyNavKey import com.team.prezel.feature.splash.api.SplashNavKey import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentMapOf @@ -29,7 +29,7 @@ private val HISTORY = TopLevelNavItem( titleTextId = R.string.bottom_nav_history, ) -private val PROFILE = TopLevelNavItem( +private val MY = TopLevelNavItem( iconRes = PrezelIcons.Profile, titleTextId = R.string.bottom_nav_profile, ) @@ -37,7 +37,7 @@ private val PROFILE = TopLevelNavItem( internal val MAIN_NAV_ITEMS = persistentMapOf( HomeNavKey to HOME, HistoryNavKey to HISTORY, - ProfileNavKey to PROFILE, + MyNavKey to MY, ) internal val MAIN_NAV_KEYS: ImmutableSet = MAIN_NAV_ITEMS.keys diff --git a/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt b/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt index c83a80a9..f23c4a3c 100644 --- a/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt +++ b/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt @@ -25,6 +25,7 @@ class AndroidFeatureImplConventionPlugin : Plugin { "implementation"(project(":core-navigation")) "implementation"(libs.findLibrary("androidx.navigation3.ui").get()) "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewmodel.compose").get()) + "implementation"(libs.findLibrary("timber").get()) } } } diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index 84e9bb72..b42a1270 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -10,5 +10,8 @@ android { dependencies { implementation(projects.coreNetwork) + implementation(projects.coreModel) + implementation(projects.coreDomain) + implementation(libs.kotlinx.coroutines.core) } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt new file mode 100644 index 00000000..f76e3c20 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.data.di + +import com.team.prezel.core.data.repository.UserRepositoryImpl +import com.team.prezel.core.domain.repository.profile.UserRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt new file mode 100644 index 00000000..168c4041 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt @@ -0,0 +1,32 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.domain.repository.profile.UserRepository +import com.team.prezel.core.model.profile.Nickname +import com.team.prezel.core.model.profile.User +import kotlinx.coroutines.delay +import javax.inject.Inject + +internal class UserRepositoryImpl @Inject constructor() : UserRepository { + private var cachedUserInfo: User? = null + + override suspend fun fetchUserInfo(isRefresh: Boolean): Result = + runCatching { + if (!isRefresh && cachedUserInfo != null) return Result.success(cachedUserInfo!!) + + User( + id = 1, + email = "test@gmail.com", + nickname = "", + profileImage = User.ProfileImage(url = "https://picsum.photos/200", isDefault = true), + isRegistered = false, + ) + }.onSuccess { user -> + cachedUserInfo = user + } + + override suspend fun checkNicknameDuplication(nickname: Nickname): Result { + // todo: 실제 API 연동 후 서버 중복 검사 결과를 반환하도록 교체 + delay(200) + return Result.success(false) + } +} diff --git a/Prezel/core/designsystem/build.gradle.kts b/Prezel/core/designsystem/build.gradle.kts index ab87ab2b..232071f4 100644 --- a/Prezel/core/designsystem/build.gradle.kts +++ b/Prezel/core/designsystem/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.coil.kt.compose) implementation(libs.kotlinx.datetime) + implementation(libs.timber) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt index 41d27090..13e98d7c 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage +import timber.log.Timber @Composable fun PrezelAsyncImage( @@ -22,7 +23,10 @@ fun PrezelAsyncImage( modifier = modifier, placeholder = ColorPainter(Color.Transparent), onSuccess = { onSuccess() }, - onError = { onError(it.result.throwable) }, + onError = { error -> + onError(error.result.throwable) + Timber.e(t = error.result.throwable) + }, contentScale = contentScale, ) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAvatar.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAvatar.kt index 7a95d136..a69d58c3 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAvatar.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAvatar.kt @@ -30,7 +30,7 @@ fun PrezelAvatar( imageUrl: String?, contentDescription: String, modifier: Modifier = Modifier, - size: PrezelAvatarSize = PrezelAvatarSize.SMALL, + size: PrezelAvatarSize = PrezelAvatarSize.REGULAR, ) { var isError by remember(imageUrl) { mutableStateOf(false) } val shape = PrezelTheme.shapes.V1000 @@ -47,8 +47,7 @@ fun PrezelAvatar( ), contentAlignment = Alignment.Center, ) { - val shouldShowDefault = - imageUrl.isNullOrBlank() || isError + val shouldShowDefault = imageUrl.isNullOrBlank() || isError if (shouldShowDefault) { DefaultAvatarIcon( @@ -103,7 +102,7 @@ private fun DefaultAvatarIcon( painter = painterResource(R.drawable.core_designsystem_ic_person), contentDescription = contentDescription, modifier = Modifier.size(prezelAvatarIconSize(size)), - tint = PrezelTheme.colors.iconDisabled, + tint = PrezelTheme.colors.iconRegular, ) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt index 90608922..5b3f8dad 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt @@ -164,7 +164,10 @@ private fun PrezelTextFieldDecorationBox( .padding(PrezelTheme.spacing.V12), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { innerTextField() if (showPlaceholder) PrezelTextFieldPlaceholder(placeholder = placeholder) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextFieldState.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextFieldState.kt index 5e72a4aa..0f064bcf 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextFieldState.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextFieldState.kt @@ -212,7 +212,7 @@ internal fun rememberPrezelTextFieldInteraction( value: String, enabled: Boolean, focused: Boolean, - idleMillis: Long = 800L, + idleMillis: Long = 500L, ): PrezelTextFieldInteraction { var isIdle by remember { mutableStateOf(false) } val latestValue by rememberUpdatedState(value) diff --git a/Prezel/core/domain/.gitignore b/Prezel/core/domain/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/Prezel/core/domain/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Prezel/core/domain/build.gradle.kts b/Prezel/core/domain/build.gradle.kts new file mode 100644 index 00000000..bfa0805f --- /dev/null +++ b/Prezel/core/domain/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.prezel.jvm.library) +} + +dependencies { + implementation(projects.coreModel) + implementation(libs.javax.inject) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt new file mode 100644 index 00000000..a8d87443 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.domain.repository.profile + +import com.team.prezel.core.model.profile.Nickname +import com.team.prezel.core.model.profile.User + +interface UserRepository { + suspend fun fetchUserInfo(isRefresh: Boolean): Result + + suspend fun checkNicknameDuplication(nickname: Nickname): Result +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserInfoUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserInfoUseCase.kt new file mode 100644 index 00000000..f4705553 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserInfoUseCase.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.domain.usecase.user + +import com.team.prezel.core.domain.repository.profile.UserRepository +import com.team.prezel.core.model.profile.User +import javax.inject.Inject + +/** + * 유저 데이터를 조회하는 UseCase. + */ +class FetchUserInfoUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + suspend operator fun invoke(isRefresh: Boolean = false): Result = userRepository.fetchUserInfo(isRefresh = isRefresh) +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/ValidateNicknameUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/ValidateNicknameUseCase.kt new file mode 100644 index 00000000..c99943aa --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/ValidateNicknameUseCase.kt @@ -0,0 +1,52 @@ +package com.team.prezel.core.domain.usecase.user + +import com.team.prezel.core.domain.repository.profile.UserRepository +import com.team.prezel.core.model.profile.Nickname +import javax.inject.Inject + +/** + * 닉네임 유효성 및 중복 여부를 검증하는 UseCase. + * + * ### 동작 흐름 + * 1. 입력된 문자열을 기반으로 [com.team.prezel.core.model.profile.Nickname.Companion.create]를 호출하여 도메인 규칙에 맞는 닉네임인지 검증합니다. + * 2. 닉네임 생성에 성공한 경우, [com.team.prezel.core.domain.repository.profile.UserRepository.checkNicknameDuplication]을 통해 서버에 중복 여부를 확인합니다. + * 3. 각 단계의 결과에 따라 [Result]를 반환합니다. + * + */ +class ValidateNicknameUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + suspend operator fun invoke(nickname: String): Result = + when (val creationResult = Nickname.create(nickname)) { + is Nickname.CreationResult.Success -> { + userRepository.checkNicknameDuplication(creationResult.nickname).fold( + onSuccess = { isDuplicated -> + if (isDuplicated) Result.Invalid.Duplicated else Result.Available(creationResult.nickname) + }, + onFailure = { throwable -> + Result.Error(throwable) + }, + ) + } + + is Nickname.CreationResult.Failure -> Result.Invalid.Format(reason = creationResult.reason) + } + + sealed interface Result { + data class Available( + val nickname: Nickname, + ) : Result + + sealed interface Invalid : Result { + data class Format( + val reason: Nickname.InvalidReason, + ) : Invalid + + data object Duplicated : Invalid + } + + data class Error( + val throwable: Throwable, + ) : Result + } +} diff --git a/Prezel/core/domain/src/test/kotlin/com/team/prezel/core/domain/.gitkeep b/Prezel/core/domain/src/test/kotlin/com/team/prezel/core/domain/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/Nickname.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/Nickname.kt new file mode 100644 index 00000000..15a3e54b --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/Nickname.kt @@ -0,0 +1,53 @@ +package com.team.prezel.core.model.profile + +@JvmInline +value class Nickname private constructor( + val value: String, +) { + sealed interface CreationResult { + data class Success( + val nickname: Nickname, + ) : CreationResult + + data class Failure( + val reason: InvalidReason, + ) : CreationResult + } + + enum class InvalidReason { + TOO_SHORT, + TOO_LONG, + INVALID_CHARACTER, + } + + companion object { + const val MAX_LENGTH = 10 + private const val MIN_LENGTH = 2 + private const val LATIN_UPPERCASE_START = 'A'.code + private const val LATIN_UPPERCASE_END = 'Z'.code + private const val LATIN_LOWERCASE_START = 'a'.code + private const val LATIN_LOWERCASE_END = 'z'.code + + fun create(value: String): CreationResult = + when (val reason = invalidReasonOf(value)) { + null -> CreationResult.Success(Nickname(value)) + else -> CreationResult.Failure(reason) + } + + private fun invalidReasonOf(value: String): InvalidReason? { + val length = value.codePointCount(0, value.length) + + return when { + length < MIN_LENGTH -> InvalidReason.TOO_SHORT + length > MAX_LENGTH -> InvalidReason.TOO_LONG + !value.codePoints().allMatch(::isAllowedCodePoint) -> InvalidReason.INVALID_CHARACTER + else -> null + } + } + + private fun isAllowedCodePoint(codePoint: Int): Boolean = + codePoint in LATIN_UPPERCASE_START..LATIN_UPPERCASE_END || + codePoint in LATIN_LOWERCASE_START..LATIN_LOWERCASE_END || + Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HANGUL + } +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/User.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/User.kt new file mode 100644 index 00000000..62c89f8d --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/User.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.model.profile + +data class User( + val id: Long, + val email: String, + val nickname: String, + val profileImage: ProfileImage, + val isRegistered: Boolean, +) { + data class ProfileImage( + val url: String, + val isDefault: Boolean, + ) +} diff --git a/Prezel/core/model/src/test/java/com/team/prezel/core/.gitkeep b/Prezel/core/model/src/test/java/com/team/prezel/core/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/AdvancedImePadding.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/AdvancedImePadding.kt new file mode 100644 index 00000000..41336fcf --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/AdvancedImePadding.kt @@ -0,0 +1,27 @@ +package com.team.prezel.core.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import kotlin.math.roundToInt + +fun Modifier.advancedImePadding() = + composed { + var consumePadding by remember { mutableIntStateOf(0) } + onGloballyPositioned { coordinates -> + consumePadding = coordinates.findRootCoordinates().size.height - + (coordinates.positionInRoot().y + coordinates.size.height).roundToInt() + }.consumeWindowInsets( + PaddingValues(bottom = with(LocalDensity.current) { consumePadding.toDp() }), + ).imePadding() + } diff --git a/Prezel/feature/login/impl/build.gradle.kts b/Prezel/feature/login/impl/build.gradle.kts index 5a113b2e..bcc02c3e 100644 --- a/Prezel/feature/login/impl/build.gradle.kts +++ b/Prezel/feature/login/impl/build.gradle.kts @@ -2,6 +2,7 @@ import com.team.prezel.buildlogic.convention.external.localProperty plugins { alias(libs.plugins.prezel.android.feature.impl) + alias(libs.plugins.kotlinx.serialization) } android { @@ -20,5 +21,6 @@ android { dependencies { implementation(projects.coreAuth) implementation(projects.featureLoginApi) + implementation(projects.featureProfileApi) implementation(projects.featureHomeApi) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index bb6ebecc..a018d390 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -56,6 +56,7 @@ private const val AUTH_SHARED_ELEMENT_TRANSITION_DELAY = 400 internal fun SharedTransitionScope.LoginScreen( authManager: AuthManager, navigateToTerms: () -> Unit, + navigateToHome: () -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel(), ) { @@ -75,6 +76,8 @@ internal fun SharedTransitionScope.LoginScreen( LoginUiEffect.NavigateToTerms -> navigateToTerms() + LoginUiEffect.NavigateToHome -> navigateToHome() + is LoginUiEffect.ShowMessage -> { val resId = when (effect.message) { LoginUiMessage.LoginCancelled -> R.string.feature_login_impl_kakao_cancelled diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 22418472..327eb366 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -40,16 +40,29 @@ internal class LoginViewModel @Inject constructor() : BaseViewModel sendEffect(LoginUiEffect.NavigateToTerms) - AuthResult.Cancelled -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) - is AuthResult.Failure -> sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) + AuthResult.Success -> fetchMyInfo() + AuthResult.Cancelled -> { + sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) + updateState { copy(isLoading = false) } + } + + is AuthResult.Failure -> { + sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) + updateState { copy(isLoading = false) } + } } } } + private fun fetchMyInfo() { + viewModelScope + .launch { + val isProfileCreateComplete = true + if (isProfileCreateComplete) sendEffect(LoginUiEffect.NavigateToHome) else sendEffect(LoginUiEffect.NavigateToTerms) + }.invokeOnCompletion { updateState { copy(isLoading = false) } } + } + private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = when (this) { AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index cfc5af93..4c3a7047 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -11,6 +11,8 @@ internal sealed interface LoginUiEffect : UiEffect { data object NavigateToTerms : LoginUiEffect + data object NavigateToHome : LoginUiEffect + data class ShowMessage( val message: LoginUiMessage, ) : LoginUiEffect diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt index 170e2228..efe85502 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt @@ -9,6 +9,7 @@ import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.feature.login.impl.landing.LoginScreen import com.team.prezel.feature.login.impl.terms.TermsScreen +import com.team.prezel.feature.profile.api.ProfileNavKey import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,6 +26,9 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au navigateToTerms = { navigator.navigate(LoginTermsNavKey) }, + navigateToHome = { + navigator.replaceRoot(HomeNavKey) + }, ) } } @@ -36,8 +40,8 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au navigateBack = { navigator.goBack() }, - navigateToHome = { - navigator.replaceRoot(HomeNavKey) + navigateToProfile = { + navigator.navigate(ProfileNavKey.Create) }, ) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsScreen.kt index 043f7ee1..e98f5c3f 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsScreen.kt @@ -50,7 +50,7 @@ import com.team.prezel.feature.login.impl.terms.contract.TermsUiState @Composable internal fun TermsScreen( navigateBack: () -> Unit, - navigateToHome: () -> Unit, + navigateToProfile: () -> Unit, modifier: Modifier = Modifier, viewModel: TermsViewModel = hiltViewModel(), ) { @@ -60,7 +60,7 @@ internal fun TermsScreen( LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { - TermsUiEffect.NavigateToHome -> navigateToHome() + TermsUiEffect.NavigateToProfile -> navigateToProfile() } } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt index 44f14895..e294f70e 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt @@ -48,7 +48,7 @@ internal class TermsViewModel @Inject constructor() : BaseViewModel.featureMyEntryBuilder() { + entry { + MyScreen() + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeatureMyModule { + @IntoSet + @Provides + fun provideFeatureMyEntryBuilder(): EntryProviderScope.() -> Unit = + { + featureMyEntryBuilder() + } +} diff --git a/Prezel/feature/my/impl/src/main/res/values/strings.xml b/Prezel/feature/my/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..545704f2 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/res/values/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/Prezel/feature/profile/api/src/main/java/com/team/prezel/feature/profile/api/ProfileNavKey.kt b/Prezel/feature/profile/api/src/main/java/com/team/prezel/feature/profile/api/ProfileNavKey.kt index e022feec..4091b582 100644 --- a/Prezel/feature/profile/api/src/main/java/com/team/prezel/feature/profile/api/ProfileNavKey.kt +++ b/Prezel/feature/profile/api/src/main/java/com/team/prezel/feature/profile/api/ProfileNavKey.kt @@ -4,4 +4,10 @@ import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -data object ProfileNavKey : NavKey +sealed interface ProfileNavKey : NavKey { + @Serializable + data object Create : ProfileNavKey + + @Serializable + data object Edit : ProfileNavKey +} diff --git a/Prezel/feature/profile/impl/build.gradle.kts b/Prezel/feature/profile/impl/build.gradle.kts index cff63da9..ffd5c27a 100644 --- a/Prezel/feature/profile/impl/build.gradle.kts +++ b/Prezel/feature/profile/impl/build.gradle.kts @@ -7,5 +7,9 @@ android { } dependencies { + implementation(projects.coreDomain) + implementation(projects.coreModel) + implementation(projects.featureProfileApi) + implementation(projects.featureHomeApi) } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 9bb8e26b..7057f354 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -1,18 +1,185 @@ package com.team.prezel.feature.profile.impl -import androidx.compose.foundation.layout.Box +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding 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.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.profile.User +import com.team.prezel.core.ui.LocalSnackbarHostState +import com.team.prezel.core.ui.advancedImePadding +import com.team.prezel.feature.profile.impl.component.NicknameTextField +import com.team.prezel.feature.profile.impl.component.ProfileImageEditor +import com.team.prezel.feature.profile.impl.component.ProfileScreenTopAppBar +import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect +import com.team.prezel.feature.profile.impl.contract.ProfileUiIntent +import com.team.prezel.feature.profile.impl.contract.ProfileUiState +import com.team.prezel.feature.profile.impl.model.NicknameValidationState +import com.team.prezel.feature.profile.impl.model.ProfileUiMessage @Composable -fun ProfileScreen(modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, +internal fun ProfileScreen( + isNewProfile: Boolean, + navigateToHome: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = LocalSnackbarHostState.current + val resources = LocalResources.current + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + viewModel.onIntent(ProfileUiIntent.UpdateProfileImage(profileUrl = uri.toString())) + } + + LaunchedEffect(Unit) { + viewModel.onIntent(ProfileUiIntent.FetchData) + + viewModel.uiEffect.collect { effect -> + when (effect) { + ProfileUiEffect.NavigateToHome -> navigateToHome() + ProfileUiEffect.NavigateToBack -> onBack() + is ProfileUiEffect.ShowMessage -> { + val resId = when (effect.message) { + ProfileUiMessage.CHECK_NICKNAME_FAILED -> R.string.feature_profile_impl_check_nickname_failed_message + ProfileUiMessage.FETCH_USER_INFO_FAILED -> R.string.feature_profile_impl_fetch_user_info_failed_message + } + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + } + } + } + } + + ProfileScreen( + uiState = uiState, + isNewProfile = isNewProfile, + onNicknameChanged = { nickname -> viewModel.onIntent(ProfileUiIntent.UpdateNickname(nickname)) }, + onClickProfileImage = { + if (uiState.shouldLaunchPhotoPicker) { + photoPickerLauncher.launch( + PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + return@ProfileScreen + } + + viewModel.onIntent(ProfileUiIntent.UpdateProfileImage(profileUrl = "")) + }, + onClickSubmit = { viewModel.onIntent(ProfileUiIntent.SubmitProfile) }, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun ProfileScreen( + uiState: ProfileUiState, + isNewProfile: Boolean, + onNicknameChanged: (String) -> Unit, + onClickProfileImage: () -> Unit, + onClickSubmit: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val contentState = uiState as? ProfileUiState.Content + val submitButtonText = stringResource(R.string.feature_profile_impl_submit_button_text) + + Column(modifier = modifier.fillMaxSize()) { + ProfileScreenTopAppBar( + isCreate = isNewProfile, + onBack = onBack, + ) + + ProfileScreenContent( + profileUrl = contentState?.profileImage?.url.orEmpty(), + isDefaultProfileImage = contentState?.profileImage?.isDefault ?: true, + nickname = contentState?.nickname.orEmpty(), + onNicknameChanged = onNicknameChanged, + nicknameValidationState = contentState?.nicknameValidation ?: NicknameValidationState.Unchecked, + onClickProfileImage = onClickProfileImage, + modifier = Modifier.weight(1f), + ) + + PrezelButtonArea( + showBackground = true, + modifier = Modifier.advancedImePadding(), + ) { + MainButton( + label = submitButtonText, + enabled = contentState?.submitButtonEnabled ?: false, + onClick = onClickSubmit, + ) + } + } +} + +@Composable +private fun ProfileScreenContent( + profileUrl: String, + isDefaultProfileImage: Boolean, + nickname: String, + nicknameValidationState: NicknameValidationState, + onNicknameChanged: (String) -> Unit, + onClickProfileImage: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(horizontal = PrezelTheme.spacing.V20) + .padding(top = PrezelTheme.spacing.V16), ) { - Text("Profile") + ProfileImageEditor( + profileUrl = profileUrl, + isDefaultProfileImage = isDefaultProfileImage, + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onClickProfileImage, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + NicknameTextField( + nickname = nickname, + onNicknameChanged = onNicknameChanged, + nicknameValidationState = nicknameValidationState, + ) + } +} + +@BasicPreview +@Composable +private fun CreateProfileScreenPreview() { + PrezelTheme { + ProfileScreen( + uiState = ProfileUiState.Content( + originalNickname = "", + originalProfileImage = User.ProfileImage(url = "", isDefault = true), + nickname = "", + nicknameValidation = NicknameValidationState.Unchecked, + profileImage = User.ProfileImage(url = "", isDefault = true), + ), + isNewProfile = true, + onNicknameChanged = {}, + onClickProfileImage = {}, + onClickSubmit = {}, + onBack = {}, + ) } } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt deleted file mode 100644 index f84d216e..00000000 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.feature.profile.impl - -sealed interface ProfileUiState { - data object Loading : ProfileUiState - - data object LoadFailed : ProfileUiState -} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index ac728c71..9778bac2 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -1,10 +1,158 @@ package com.team.prezel.feature.profile.impl -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.user.FetchUserInfoUseCase +import com.team.prezel.core.domain.usecase.user.ValidateNicknameUseCase +import com.team.prezel.core.model.profile.Nickname +import com.team.prezel.core.model.profile.User +import com.team.prezel.core.ui.BaseViewModel +import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect +import com.team.prezel.feature.profile.impl.contract.ProfileUiIntent +import com.team.prezel.feature.profile.impl.contract.ProfileUiState +import com.team.prezel.feature.profile.impl.contract.ProfileUiState.Content.Companion.toUiState +import com.team.prezel.feature.profile.impl.model.NicknameValidationState +import com.team.prezel.feature.profile.impl.model.ProfileUiMessage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject +@OptIn(FlowPreview::class) @HiltViewModel -class ProfileViewModel - @Inject - constructor() : ViewModel() +internal class ProfileViewModel @Inject constructor( + private val fetchUserInfoUseCase: FetchUserInfoUseCase, + private val validateNicknameUseCase: ValidateNicknameUseCase, +) : BaseViewModel(ProfileUiState.Loading) { + private val nicknameChanges = MutableStateFlow(null) + + init { + viewModelScope.launch { + nicknameChanges + .filterNotNull() + .debounce(NICKNAME_VALIDATION_DEBOUNCE_MILLIS) + .distinctUntilChanged() + .collectLatest(::validateNickname) + } + } + + override fun onIntent(intent: ProfileUiIntent) { + when (intent) { + ProfileUiIntent.FetchData -> fetchUserInfo() + is ProfileUiIntent.UpdateNickname -> handleNicknameChanged(intent.nickname) + is ProfileUiIntent.UpdateProfileImage -> handleProfileImageChanged(intent.profileUrl) + ProfileUiIntent.SubmitProfile -> submitProfile() + } + } + + private fun fetchUserInfo() { + viewModelScope.launch { + fetchUserInfoUseCase() + .onSuccess { user -> updateState { user.toUiState() } } + .onFailure { throwable -> + sendEffect(ProfileUiEffect.ShowMessage(ProfileUiMessage.FETCH_USER_INFO_FAILED)) + Timber.e(throwable) + } + } + } + + private fun handleNicknameChanged(nickname: String) { + val uiState = currentState as? ProfileUiState.Content ?: return + + val sanitizedNickname = nickname + .filterNot(Char::isWhitespace) + .take(Nickname.MAX_LENGTH) + if (sanitizedNickname == uiState.nickname) return + + updateState { + val validationState = when { + sanitizedNickname.isBlank() && uiState.nickname.isNotBlank() -> NicknameValidationState.TooShort + sanitizedNickname.isBlank() -> NicknameValidationState.Unchecked + else -> NicknameValidationState.Checking + } + + uiState.copy( + nickname = sanitizedNickname, + nicknameValidation = validationState, + ) + } + + nicknameChanges.value = sanitizedNickname + } + + private fun handleProfileImageChanged(profileUrl: String) { + val uiState = currentState as? ProfileUiState.Content ?: return + if (profileUrl == uiState.profileImage.url) return + + updateState { + uiState.copy( + profileImage = User.ProfileImage( + url = profileUrl, + isDefault = profileUrl.isBlank(), + ), + ) + } + } + + private suspend fun validateNickname(nickname: String) { + if (nickname.isBlank()) return + + val validationState = when (val result = validateNicknameUseCase(nickname)) { + is ValidateNicknameUseCase.Result.Available -> NicknameValidationState.Available + is ValidateNicknameUseCase.Result.Invalid -> { + when (result) { + is ValidateNicknameUseCase.Result.Invalid.Format -> result.reason.toValidationState() + is ValidateNicknameUseCase.Result.Invalid.Duplicated -> NicknameValidationState.Duplicated + } + } + + is ValidateNicknameUseCase.Result.Error -> { + sendEffect(ProfileUiEffect.ShowMessage(ProfileUiMessage.CHECK_NICKNAME_FAILED)) + NicknameValidationState.Unchecked + } + } + + updateState { + val uiState = currentState as? ProfileUiState.Content ?: return@updateState currentState + if (uiState.nickname != nickname) return@updateState currentState + uiState.copy(nicknameValidation = validationState) + } + } + + private fun Nickname.InvalidReason.toValidationState(): NicknameValidationState = + when (this) { + Nickname.InvalidReason.TOO_SHORT -> NicknameValidationState.TooShort + Nickname.InvalidReason.TOO_LONG -> NicknameValidationState.TooLong + Nickname.InvalidReason.INVALID_CHARACTER -> NicknameValidationState.InvalidCharacter + } + + private fun submitProfile() { + val uiState = currentState as? ProfileUiState.Content ?: return + if (!uiState.submitButtonEnabled) return + + viewModelScope.launch { + // todo: 프로필 수정 API 호출 필요 +// patchUserProfileUseCase(fetchedState.profileImage, fetchedState.nickname) +// .onSuccess { +// when(fetchedState) { +// is ProfileUiState.Create -> ProfileUiEffect.NavigateToHome +// is ProfileUiState.Edit -> ProfileUiEffect.OnBack +// }.let(sendEffect) +// } +// .onFailure { throwable -> +// sendEffect(ProfileUiEffect.ShowMessage(ProfileUiMessage.PATCH_USER_PROFILE_FAILED)) +// Timber.e(throwable) +// } + sendEffect(ProfileUiEffect.NavigateToHome) + } + } + + private companion object { + const val NICKNAME_VALIDATION_DEBOUNCE_MILLIS = 300L + } +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/NicknameTextField.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/NicknameTextField.kt new file mode 100644 index 00000000..59d67e7f --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/NicknameTextField.kt @@ -0,0 +1,76 @@ +package com.team.prezel.feature.profile.impl.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.textfield.PrezelTextField +import com.team.prezel.core.designsystem.component.textfield.PrezelTextFieldFeedback +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.profile.impl.R +import com.team.prezel.feature.profile.impl.model.NicknameValidationState + +@Composable +internal fun NicknameTextField( + nickname: String, + onNicknameChanged: (String) -> Unit, + nicknameValidationState: NicknameValidationState, + modifier: Modifier = Modifier, +) { + PrezelTextField( + value = nickname, + onValueChange = onNicknameChanged, + label = stringResource(R.string.feature_profile_impl_nickname_text_field_label), + placeholder = stringResource(R.string.feature_profile_impl_nickname_text_field_placeholder), + feedback = nicknameValidationState.toNicknameFeedback(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + ), + modifier = modifier.fillMaxWidth(), + ) +} + +@Composable +private fun NicknameValidationState.toNicknameFeedback(): PrezelTextFieldFeedback = + when (this) { + NicknameValidationState.Unchecked, + NicknameValidationState.Checking, + NicknameValidationState.TooLong, + -> PrezelTextFieldFeedback.NO_MESSAGE + + NicknameValidationState.Available -> PrezelTextFieldFeedback.Good( + message = stringResource(R.string.feature_profile_impl_nickname_helper_available), + ) + + NicknameValidationState.TooShort -> PrezelTextFieldFeedback.Bad( + message = stringResource(R.string.feature_profile_impl_nickname_helper_too_short), + ) + + NicknameValidationState.Duplicated -> PrezelTextFieldFeedback.Bad( + message = stringResource(R.string.feature_profile_impl_nickname_helper_duplicated), + ) + + NicknameValidationState.InvalidCharacter -> PrezelTextFieldFeedback.Bad( + message = stringResource(R.string.feature_profile_impl_nickname_helper_unavailable), + ) + } + +@BasicPreview +@Composable +private fun NicknameTextFieldPreview() { + PrezelTheme { + Box(modifier = Modifier.padding(16.dp)) { + NicknameTextField( + nickname = "", + onNicknameChanged = {}, + nicknameValidationState = NicknameValidationState.Unchecked, + ) + } + } +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileImageEditor.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileImageEditor.kt new file mode 100644 index 00000000..d67db2ed --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileImageEditor.kt @@ -0,0 +1,71 @@ +package com.team.prezel.feature.profile.impl.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.PrezelAvatar +import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.profile.impl.R + +@Composable +internal fun ProfileImageEditor( + profileUrl: String, + isDefaultProfileImage: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box(modifier = modifier) { + PrezelAvatar( + imageUrl = profileUrl, + contentDescription = stringResource(R.string.feature_profile_impl_profile_image_content_description), + ) + + PrezelIconButton( + iconResId = PrezelIcons.Plus, + type = ButtonType.FILLED, + size = ButtonSize.SMALL, + hierarchy = ButtonHierarchy.PRIMARY, + isRounded = true, + modifier = Modifier + .align(Alignment.BottomEnd) + .rotate(if (isDefaultProfileImage) 0f else 45f), + onClick = onClick, + ) + } +} + +@BasicPreview +@Composable +private fun ProfileImageEditorPreview() { + PrezelTheme { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ProfileImageEditor( + profileUrl = "", + isDefaultProfileImage = true, + onClick = {}, + ) + + ProfileImageEditor( + profileUrl = "https://picsum.photos/200", + isDefaultProfileImage = false, + onClick = {}, + ) + } + } +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileScreenTopAppBar.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileScreenTopAppBar.kt new file mode 100644 index 00000000..c49caf7d --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileScreenTopAppBar.kt @@ -0,0 +1,71 @@ +package com.team.prezel.feature.profile.impl.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.profile.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ProfileScreenTopAppBar( + isCreate: Boolean, + modifier: Modifier = Modifier, + onBack: () -> Unit, +) { + PrezelTopAppBar( + modifier = modifier, + title = { ProfileTopBarTitle(isCreate = isCreate) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_profile_impl_topbar_leading_icon_content_description), + ) + } + }, + ) +} + +@Composable +private fun ProfileTopBarTitle( + isCreate: Boolean, + modifier: Modifier = Modifier, +) { + Text( + text = stringResource( + id = if (isCreate) R.string.feature_profile_impl_topbar_create_title else R.string.feature_profile_impl_topbar_edit_title, + ), + modifier = modifier, + ) +} + +@BasicPreview +@Composable +private fun ProfileScreenTopAppBarCreatePreview() { + PrezelTheme { + ProfileScreenTopAppBar( + isCreate = true, + onBack = {}, + ) + } +} + +@BasicPreview +@Composable +private fun ProfileScreenTopAppBarEditPreview() { + PrezelTheme { + ProfileScreenTopAppBar( + isCreate = false, + onBack = {}, + ) + } +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt new file mode 100644 index 00000000..5272c49e --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt @@ -0,0 +1,14 @@ +package com.team.prezel.feature.profile.impl.contract + +import com.team.prezel.core.ui.UiEffect +import com.team.prezel.feature.profile.impl.model.ProfileUiMessage + +internal sealed interface ProfileUiEffect : UiEffect { + data object NavigateToHome : ProfileUiEffect + + data object NavigateToBack : ProfileUiEffect + + data class ShowMessage( + val message: ProfileUiMessage, + ) : ProfileUiEffect +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt new file mode 100644 index 00000000..3831ee18 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt @@ -0,0 +1,17 @@ +package com.team.prezel.feature.profile.impl.contract + +import com.team.prezel.core.ui.UiIntent + +internal sealed interface ProfileUiIntent : UiIntent { + data object FetchData : ProfileUiIntent + + data class UpdateNickname( + val nickname: String, + ) : ProfileUiIntent + + data class UpdateProfileImage( + val profileUrl: String, + ) : ProfileUiIntent + + data object SubmitProfile : ProfileUiIntent +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt new file mode 100644 index 00000000..7f34cf00 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt @@ -0,0 +1,36 @@ +package com.team.prezel.feature.profile.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.profile.User +import com.team.prezel.core.ui.UiState +import com.team.prezel.feature.profile.impl.model.NicknameValidationState + +@Immutable +internal sealed interface ProfileUiState : UiState { + val shouldLaunchPhotoPicker get(): Boolean = (this as? Content)?.profileImage?.isDefault == true + + data object Loading : ProfileUiState + + data class Content( + private val originalNickname: String, + private val originalProfileImage: User.ProfileImage, + val nickname: String, + val nicknameValidation: NicknameValidationState, + val profileImage: User.ProfileImage, + ) : ProfileUiState { + val submitButtonEnabled: Boolean = + nicknameValidation == NicknameValidationState.Available && + (nickname != originalNickname || profileImage != originalProfileImage) + + companion object { + fun User.toUiState(): ProfileUiState = + Content( + originalNickname = nickname, + originalProfileImage = profileImage, + nickname = nickname, + nicknameValidation = if (isRegistered) NicknameValidationState.Available else NicknameValidationState.Unchecked, + profileImage = profileImage, + ) + } + } +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/NicknameValidationState.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/NicknameValidationState.kt new file mode 100644 index 00000000..b5006440 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/NicknameValidationState.kt @@ -0,0 +1,11 @@ +package com.team.prezel.feature.profile.impl.model + +internal enum class NicknameValidationState { + Unchecked, + Checking, + Available, + TooShort, + TooLong, + InvalidCharacter, + Duplicated, +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt new file mode 100644 index 00000000..150e2d83 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt @@ -0,0 +1,6 @@ +package com.team.prezel.feature.profile.impl.model + +enum class ProfileUiMessage { + CHECK_NICKNAME_FAILED, + FETCH_USER_INFO_FAILED, +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/navigation/ProfileEntryBuilder.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/navigation/ProfileEntryBuilder.kt index cd28b717..bcfd5321 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/navigation/ProfileEntryBuilder.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/navigation/ProfileEntryBuilder.kt @@ -2,6 +2,8 @@ package com.team.prezel.feature.profile.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.profile.api.ProfileNavKey import com.team.prezel.feature.profile.impl.ProfileScreen import dagger.Module @@ -11,8 +13,24 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureProfileEntryBuilder() { - entry { - ProfileScreen() + entry { + val navigator = LocalNavigator.current + + ProfileScreen( + isNewProfile = true, + navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + onBack = { navigator.goBack() }, + ) + } + + entry { + val navigator = LocalNavigator.current + + ProfileScreen( + isNewProfile = false, + navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + onBack = { navigator.goBack() }, + ) } } diff --git a/Prezel/feature/profile/impl/src/main/res/values/strings.xml b/Prezel/feature/profile/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..a4ef587f --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + 프로필 생성 + 프로필 편집 + 닉네임 + 완료 + + 뒤로가기 + 프로필 + + + 최대 10자까지 입력이 가능해요 + 사용 가능한 닉네임이에요. + 2자 이상 입력해 주세요. + 이미 사용하고 있는 닉네임이에요. + 사용할 수 없는 닉네임이에요. + + + 닉네임 중복 확인에 실패했어요. + 유저 정보 조회에 실패했어요. + diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 4493ca8d..e5015a46 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.13.2" kotlin = "2.3.0" +javaxInject = "1" coil = "2.7.0" coreKtx = "1.17.0" junit = "4.13.2" @@ -51,6 +52,7 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } kotlin-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInject" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 9bc7cba1..3dc802f7 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -42,6 +42,7 @@ includeAuto( "core:auth", ":core:data", ":core:designsystem", + ":core:domain", ":core:model", ":core:network", ":core:navigation", @@ -50,6 +51,8 @@ includeAuto( ":feature:home:impl", ":feature:history:api", ":feature:history:impl", + ":feature:my:api", + ":feature:my:impl", ":feature:profile:api", ":feature:profile:impl", ":feature:login:api",