From 858ed0d2e166175d50f775966d5ca91d168211ce Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 13 Apr 2026 16:27:27 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20My(=EB=A7=88=EC=9D=B4)=20?= =?UTF-8?q?=ED=94=BC=EC=B2=98=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `My` 피처를 위한 API 및 Implementation 모듈을 신규 생성하고, 메인 하단 탭 내비게이션에 추가하였습니다. * **feat: My 피처 모듈 추가** * `:feature:my:api` 및 `:feature:my:impl` 모듈 생성 및 프로젝트 설정(`settings.gradle.kts`, `app/build.gradle.kts`) 반영 * `MyNavKey`: 내비게이션을 위한 직렬화 가능한 데이터 객체 정의 * `MyScreen`, `MyViewModel`, `MyUiState`: 기본적인 UI 컴포넌트 및 상태 관리 로직 구현 * `MyEntryBuilder`: Hilt와 Navigation3를 이용한 화면 진입점 등록 및 의존성 주입 설정 * **feat: 메인 내비게이션에 My 탭 추가** * `TopLevelNavItem`: 하단 네비게이션 아이템 목록(`MAIN_NAV_ITEMS`)에 `MyNavKey` 추가 * `strings.xml`: 하단 탭 표시를 위한 리소스(`bottom_nav_my`) 추가 --- Prezel/app/build.gradle.kts | 2 ++ .../team/prezel/navigation/TopLevelNavItem.kt | 7 +++++ Prezel/app/src/main/res/values/strings.xml | 1 + Prezel/feature/my/api/build.gradle.kts | 7 +++++ .../team/prezel/feature/my/api/MyNavKey.kt | 7 +++++ Prezel/feature/my/impl/build.gradle.kts | 11 ++++++++ .../team/prezel/feature/my/impl/MyScreen.kt | 18 ++++++++++++ .../team/prezel/feature/my/impl/MyUiState.kt | 7 +++++ .../prezel/feature/my/impl/MyViewModel.kt | 10 +++++++ .../my/impl/navigation/MyEntryBuilder.kt | 28 +++++++++++++++++++ .../my/impl/src/main/res/values/strings.xml | 2 ++ Prezel/settings.gradle.kts | 2 ++ 12 files changed, 102 insertions(+) create mode 100644 Prezel/feature/my/api/build.gradle.kts create mode 100644 Prezel/feature/my/api/src/main/java/com/team/prezel/feature/my/api/MyNavKey.kt create mode 100644 Prezel/feature/my/impl/build.gradle.kts create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt create mode 100644 Prezel/feature/my/impl/src/main/res/values/strings.xml diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 17a570a2..43035263 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -44,6 +44,8 @@ dependencies { implementation(projects.featureHomeImpl) implementation(projects.featureHistoryApi) implementation(projects.featureHistoryImpl) + implementation(projects.featureMyApi) + implementation(projects.featureMyImpl) implementation(projects.featureProfileApi) implementation(projects.featureProfileImpl) 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..93b5ef8c 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,6 +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.my.api.MyNavKey import com.team.prezel.feature.profile.api.ProfileNavKey import com.team.prezel.feature.splash.api.SplashNavKey import kotlinx.collections.immutable.ImmutableSet @@ -34,10 +35,16 @@ private val PROFILE = TopLevelNavItem( titleTextId = R.string.bottom_nav_profile, ) +private val MY = TopLevelNavItem( + iconRes = PrezelIcons.Profile, + titleTextId = R.string.bottom_nav_my, +) + 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/app/src/main/res/values/strings.xml b/Prezel/app/src/main/res/values/strings.xml index b8740117..0a708f67 100644 --- a/Prezel/app/src/main/res/values/strings.xml +++ b/Prezel/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 히스토리 프로필 + 마이 한 번 더 누르면 앱을 종료합니다 닫기 diff --git a/Prezel/feature/my/api/build.gradle.kts b/Prezel/feature/my/api/build.gradle.kts new file mode 100644 index 00000000..bd96245c --- /dev/null +++ b/Prezel/feature/my/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.android.feature.api) +} + +android { + namespace = "com.team.prezel.feature.my.api" +} diff --git a/Prezel/feature/my/api/src/main/java/com/team/prezel/feature/my/api/MyNavKey.kt b/Prezel/feature/my/api/src/main/java/com/team/prezel/feature/my/api/MyNavKey.kt new file mode 100644 index 00000000..2b89e072 --- /dev/null +++ b/Prezel/feature/my/api/src/main/java/com/team/prezel/feature/my/api/MyNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object MyNavKey : NavKey diff --git a/Prezel/feature/my/impl/build.gradle.kts b/Prezel/feature/my/impl/build.gradle.kts new file mode 100644 index 00000000..36c7169b --- /dev/null +++ b/Prezel/feature/my/impl/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.my.impl" +} + +dependencies { + implementation(projects.featureMyApi) +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt new file mode 100644 index 00000000..d98dcb14 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt @@ -0,0 +1,18 @@ +package com.team.prezel.feature.my.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun MyScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text("My") + } +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt new file mode 100644 index 00000000..c5d3a633 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.impl + +sealed interface MyUiState { + data object Loading : MyUiState + + data object LoadFailed : MyUiState +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt new file mode 100644 index 00000000..657f39f1 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.my.impl + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MyViewModel + @Inject + constructor() : ViewModel() diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt new file mode 100644 index 00000000..b26506c2 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt @@ -0,0 +1,28 @@ +package com.team.prezel.feature.my.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.team.prezel.feature.my.api.MyNavKey +import com.team.prezel.feature.my.impl.MyScreen +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +internal fun EntryProviderScope.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/settings.gradle.kts b/Prezel/settings.gradle.kts index 9bc7cba1..810ae5f6 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -50,6 +50,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", From 23a6b319ea9e6cefe7bae0a8b539374e95c9c910 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 13 Apr 2026 16:36:38 +0900 Subject: [PATCH 02/34] =?UTF-8?q?refactor:=20=EB=B0=94=ED=85=80=20?= =?UTF-8?q?=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20ProfileScreen?= =?UTF-8?q?=20->=20MyScreen=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Prezel/app/build.gradle.kts | 2 -- .../java/com/team/prezel/navigation/TopLevelNavItem.kt | 9 +-------- Prezel/app/src/main/res/values/strings.xml | 1 - 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 43035263..e1d4f4c6 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -46,8 +46,6 @@ dependencies { implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) implementation(projects.featureMyImpl) - implementation(projects.featureProfileApi) - implementation(projects.featureProfileImpl) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) 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 93b5ef8c..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 @@ -9,7 +9,6 @@ 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.my.api.MyNavKey -import com.team.prezel.feature.profile.api.ProfileNavKey import com.team.prezel.feature.splash.api.SplashNavKey import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentMapOf @@ -30,20 +29,14 @@ private val HISTORY = TopLevelNavItem( titleTextId = R.string.bottom_nav_history, ) -private val PROFILE = TopLevelNavItem( - iconRes = PrezelIcons.Profile, - titleTextId = R.string.bottom_nav_profile, -) - private val MY = TopLevelNavItem( iconRes = PrezelIcons.Profile, - titleTextId = R.string.bottom_nav_my, + titleTextId = R.string.bottom_nav_profile, ) internal val MAIN_NAV_ITEMS = persistentMapOf( HomeNavKey to HOME, HistoryNavKey to HISTORY, - ProfileNavKey to PROFILE, MyNavKey to MY, ) diff --git a/Prezel/app/src/main/res/values/strings.xml b/Prezel/app/src/main/res/values/strings.xml index 0a708f67..b8740117 100644 --- a/Prezel/app/src/main/res/values/strings.xml +++ b/Prezel/app/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ 히스토리 프로필 - 마이 한 번 더 누르면 앱을 종료합니다 닫기 From caeb228fbaca00261cc3e866163e87ce01a8fd49 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 16:34:34 +0900 Subject: [PATCH 03/34] =?UTF-8?q?refactor:=20PrezelTextField=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EC=A0=95=EB=A0=AC=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PrezelTextField`의 `innerTextField`와 `placeholder`를 포함하는 `Box` 컴포저블에 `contentAlignment = Alignment.CenterStart` 속성을 추가하여 내부 콘텐츠가 수직 중앙 및 시작 지점에 올바르게 정렬되도록 수정했습니다. --- .../core/designsystem/component/textfield/PrezelTextField.kt | 5 ++++- .../designsystem/component/textfield/PrezelTextFieldState.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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) From 546a9ff57881dc7efd43ebde06cca3cf7d07ba42 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 17:45:39 +0900 Subject: [PATCH 04/34] =?UTF-8?q?refactor:=20PrezelAvatar=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prezel/core/designsystem/component/PrezelAvatar.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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, ) } From 3edddffc1d642c377624c488ec5708e227415e96 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 18:08:30 +0900 Subject: [PATCH 05/34] =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Prezel/core/data/build.gradle.kts | 3 ++ .../prezel/core/data/di/RepositoryModule.kt | 17 ++++++ .../data/repository/UserRepositoryImpl.kt | 14 +++++ Prezel/core/domain/.gitignore | 1 + Prezel/core/domain/build.gradle.kts | 8 +++ .../repository/profile/UserRepository.kt | 7 +++ .../profile/ValidateNicknameUseCase.kt | 52 ++++++++++++++++++ .../prezel/core/model/profile/Nickname.kt | 53 +++++++++++++++++++ Prezel/gradle/libs.versions.toml | 2 + Prezel/settings.gradle.kts | 1 + 10 files changed, 158 insertions(+) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt create mode 100644 Prezel/core/domain/.gitignore create mode 100644 Prezel/core/domain/build.gradle.kts create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/profile/ValidateNicknameUseCase.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/Nickname.kt 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..7768d27c --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt @@ -0,0 +1,14 @@ +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 kotlinx.coroutines.delay +import javax.inject.Inject + +internal class UserRepositoryImpl @Inject constructor() : UserRepository { + override suspend fun checkNicknameDuplication(nickname: Nickname): Result { + // TODO: 실제 API 연동 후 서버 중복 검사 결과를 반환하도록 교체 + delay(200) + return Result.success(false) + } +} 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..dd30f142 --- /dev/null +++ b/Prezel/core/domain/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.prezel.jvm.library) +} + +dependencies { + implementation(projects.coreModel) + implementation(libs.javax.inject) +} 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..4a577336 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt @@ -0,0 +1,7 @@ +package com.team.prezel.core.domain.repository.profile + +import com.team.prezel.core.model.profile.Nickname + +interface UserRepository { + suspend fun checkNicknameDuplication(nickname: Nickname): Result +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/profile/ValidateNicknameUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/profile/ValidateNicknameUseCase.kt new file mode 100644 index 00000000..cb559ad2 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/profile/ValidateNicknameUseCase.kt @@ -0,0 +1,52 @@ +package com.team.prezel.core.domain.usecase.profile + +import com.team.prezel.core.domain.repository.profile.UserRepository +import com.team.prezel.core.model.profile.Nickname +import javax.inject.Inject + +/** + * 닉네임 유효성 및 중복 여부를 검증하는 UseCase. + * + * ### 동작 흐름 + * 1. 입력된 문자열을 기반으로 [Nickname.create]를 호출하여 도메인 규칙에 맞는 닉네임인지 검증합니다. + * 2. 닉네임 생성에 성공한 경우, [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/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/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 5280d2e7..6706c193 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" @@ -50,6 +51,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 810ae5f6..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", From 946bd56afa72a595b25b73d610e883ecd13b81e2 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 18:09:46 +0900 Subject: [PATCH 06/34] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=9D=90=EB=A6=84=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Prezel/app/build.gradle.kts | 1 + Prezel/feature/login/impl/build.gradle.kts | 1 + .../feature/login/impl/landing/LoginScreen.kt | 3 + .../login/impl/landing/LoginViewModel.kt | 23 ++- .../impl/landing/contract/LoginUiEffect.kt | 2 + .../impl/navigation/LoginEntryBuilder.kt | 8 +- .../feature/login/impl/terms/TermsScreen.kt | 4 +- .../login/impl/terms/TermsViewModel.kt | 2 +- .../impl/terms/contract/TermsUiEffect.kt | 2 +- .../feature/profile/api/ProfileNavKey.kt | 11 +- Prezel/feature/profile/impl/build.gradle.kts | 4 + .../feature/profile/impl/ProfileScreen.kt | 177 +++++++++++++++++- .../feature/profile/impl/ProfileUiState.kt | 7 - .../feature/profile/impl/ProfileViewModel.kt | 114 ++++++++++- .../impl/component/ProfileScreenTopAppBar.kt | 71 +++++++ .../profile/impl/contract/ProfileUiEffect.kt | 12 ++ .../profile/impl/contract/ProfileUiIntent.kt | 11 ++ .../profile/impl/contract/ProfileUiState.kt | 60 ++++++ .../profile/impl/model/ProfileUiMessage.kt | 5 + .../impl/navigation/ProfileEntryBuilder.kt | 33 +++- .../impl/src/main/res/values/strings.xml | 17 ++ 21 files changed, 532 insertions(+), 36 deletions(-) delete mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileScreenTopAppBar.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt create mode 100644 Prezel/feature/profile/impl/src/main/res/values/strings.xml diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index e1d4f4c6..6ac709f8 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) implementation(projects.featureMyImpl) + implementation(projects.featureProfileImpl) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/Prezel/feature/login/impl/build.gradle.kts b/Prezel/feature/login/impl/build.gradle.kts index 5a113b2e..764da8cd 100644 --- a/Prezel/feature/login/impl/build.gradle.kts +++ b/Prezel/feature/login/impl/build.gradle.kts @@ -20,5 +20,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 Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ProfileViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = LocalSnackbarHostState.current + val resources = LocalResources.current + + LaunchedEffect(Unit) { + viewModel.uiEffect.collect { effect -> + when (effect) { + ProfileUiEffect.NavigateToHome -> navigateToHome() + is ProfileUiEffect.ShowMessage -> { + val resId = when (effect.message) { + ProfileUiMessage.CHECK_NICKNAME_FAILED -> R.string.feature_profile_impl_check_nickname_failed + } + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + } + } + } + } + + ProfileScreen( + uiState = uiState, + onNicknameChanged = { nickname -> + viewModel.onIntent(ProfileUiIntent.OnNicknameChanged(nickname.filterNot(Char::isWhitespace))) + }, + onClickPrimaryAction = { viewModel.onIntent(ProfileUiIntent.OnClickSubmit) }, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun ProfileScreen( + uiState: ProfileUiState, + onNicknameChanged: (String) -> Unit, + onClickPrimaryAction: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val nicknameFeedback = uiState.nicknameValidation.toNicknameFeedback() + val submitButtonText = stringResource(R.string.feature_profile_impl_submit_button_text) + + Column(modifier = modifier.fillMaxSize()) { + ProfileScreenTopAppBar( + isCreate = uiState is ProfileUiState.Create, + onBack = onBack, + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = PrezelTheme.spacing.V20) + .padding(top = PrezelTheme.spacing.V16), + ) { + PrezelAvatar( + imageUrl = null, + contentDescription = "프로필 이미지", + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(120.dp), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + PrezelTextField( + value = uiState.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), + modifier = Modifier.fillMaxWidth(), + feedback = nicknameFeedback, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + } + + PrezelButtonArea( + showBackground = true, + modifier = Modifier.imePadding(), + ) { + MainButton( + label = submitButtonText, + enabled = uiState.isPrimaryActionEnabled, + onClick = onClickPrimaryAction, + ) + } + } +} + +@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 CreateProfileScreenPreview() { + PrezelTheme { + ProfileScreen( + uiState = ProfileUiState.Create(), + onNicknameChanged = {}, + onClickPrimaryAction = {}, + onBack = {}, + ) + } +} +@BasicPreview @Composable -fun ProfileScreen(modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text("Profile") +private fun EditProfileScreenPreview() { + PrezelTheme { + ProfileScreen( + uiState = ProfileUiState.Edit(originalNickname = ""), + onNicknameChanged = {}, + onClickPrimaryAction = {}, + 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..71798804 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,112 @@ package com.team.prezel.feature.profile.impl -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.profile.ValidateNicknameUseCase +import com.team.prezel.core.model.profile.Nickname +import com.team.prezel.core.ui.BaseViewModel +import com.team.prezel.feature.profile.impl.contract.NicknameValidationState +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.ProfileUiMessage +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +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.launch -@HiltViewModel -class ProfileViewModel - @Inject - constructor() : ViewModel() +@OptIn(FlowPreview::class) +@HiltViewModel(assistedFactory = ProfileViewModel.Factory::class) +internal class ProfileViewModel @AssistedInject constructor( + @Assisted initialState: ProfileUiState, + private val validateNicknameUseCase: ValidateNicknameUseCase, +) : BaseViewModel(initialState) { + private val nicknameInput = MutableStateFlow(currentState.nickname) + + @AssistedFactory + interface Factory { + fun create(initialState: ProfileUiState): ProfileViewModel + } + + init { + viewModelScope.launch { + nicknameInput + .debounce(NICKNAME_VALIDATION_DEBOUNCE_MILLIS) + .distinctUntilChanged() + .collectLatest(::validateNickname) + } + } + + override fun onIntent(intent: ProfileUiIntent) { + when (intent) { + is ProfileUiIntent.OnNicknameChanged -> handleNicknameChanged(intent.nickname) + + ProfileUiIntent.OnClickSubmit -> submitProfile() + } + } + + private fun handleNicknameChanged(nickname: String) { + val sanitizedNickname = nickname.take(Nickname.MAX_LENGTH) + if (sanitizedNickname == currentState.nickname) return + + updateState { + val validationState = if (sanitizedNickname.isBlank()) NicknameValidationState.Unchecked else NicknameValidationState.Checking + + updateProfile( + nickname = sanitizedNickname, + nicknameValidation = validationState, + ) + } + + nicknameInput.value = sanitizedNickname + } + + private suspend fun validateNickname(nickname: String) { + if (nickname.isBlank()) { + updateState { updateProfile(nicknameValidation = NicknameValidationState.Unchecked) } + 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 { updateProfile(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() { + if (currentState.nicknameValidation != NicknameValidationState.Available) return + + viewModelScope.launch { + // todo: 닉네임 생성 API 호출 + 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/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..fcf4944e --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt @@ -0,0 +1,12 @@ +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 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..15743205 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt @@ -0,0 +1,11 @@ +package com.team.prezel.feature.profile.impl.contract + +import com.team.prezel.core.ui.UiIntent + +internal sealed interface ProfileUiIntent : UiIntent { + data class OnNicknameChanged( + val nickname: String, + ) : ProfileUiIntent + + data object OnClickSubmit : 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..e091d8e8 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt @@ -0,0 +1,60 @@ +package com.team.prezel.feature.profile.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.UiState + +@Immutable +internal sealed interface ProfileUiState : UiState { + val nickname: String + val nicknameValidation: NicknameValidationState + val isPrimaryActionEnabled: Boolean + + fun updateProfile( + nickname: String = this.nickname, + nicknameValidation: NicknameValidationState = this.nicknameValidation, + ): ProfileUiState + + data class Create( + override val nickname: String = "", + override val nicknameValidation: NicknameValidationState = NicknameValidationState.Unchecked, + ) : ProfileUiState { + override val isPrimaryActionEnabled: Boolean = nicknameValidation == NicknameValidationState.Available + + override fun updateProfile( + nickname: String, + nicknameValidation: NicknameValidationState, + ): ProfileUiState = + copy( + nickname = nickname, + nicknameValidation = nicknameValidation, + ) + } + + data class Edit( + val originalNickname: String, + override val nickname: String = originalNickname, + override val nicknameValidation: NicknameValidationState = NicknameValidationState.Available, + ) : ProfileUiState { + override val isPrimaryActionEnabled: Boolean = + nicknameValidation == NicknameValidationState.Available && nickname != originalNickname + + override fun updateProfile( + nickname: String, + nicknameValidation: NicknameValidationState, + ): ProfileUiState = + copy( + nickname = nickname, + nicknameValidation = nicknameValidation, + ) + } +} + +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..f69ecb4c --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.profile.impl.model + +enum class ProfileUiMessage { + CHECK_NICKNAME_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..243ff292 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 @@ -1,9 +1,14 @@ package com.team.prezel.feature.profile.impl.navigation +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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 com.team.prezel.feature.profile.impl.ProfileViewModel +import com.team.prezel.feature.profile.impl.contract.ProfileUiState import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -11,8 +16,32 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureProfileEntryBuilder() { - entry { - ProfileScreen() + entry { + val navigator = LocalNavigator.current + + ProfileScreen( + navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + onBack = { navigator.goBack() }, + viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(initialState = ProfileUiState.Create()) }, + ), + ) + } + + entry { key -> + val navigator = LocalNavigator.current + + ProfileScreen( + navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + onBack = { navigator.goBack() }, + viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create( + initialState = ProfileUiState.Edit(originalNickname = key.nickname), + ) + }, + ), + ) } } 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..2d99b326 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ + + 프로필 생성 + 프로필 편집 + 닉네임 + 완료 + 뒤로가기 + + + 닉네임을 입력해 주세요 + 사용 가능한 닉네임이에요. + 2자 이상 입력해 주세요. + 이미 사용하고 있는 닉네임이에요. + 사용할 수 없는 닉네임이에요. + + + 닉네임 중복 확인에 실패했어요. 잠시 후 다시 시도해 주세요. + From 91256ff1b53f9f2a871d415a7deb90e79087dc52 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 18:10:50 +0900 Subject: [PATCH 07/34] =?UTF-8?q?=EB=A7=88=EC=9D=B4=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EA=B0=80=EB=93=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Prezel/feature/my/api/consumer-rules.pro | 1 + Prezel/feature/my/api/proguard-rules.pro | 21 +++++++++++++++++++++ Prezel/feature/my/impl/consumer-rules.pro | 1 + Prezel/feature/my/impl/proguard-rules.pro | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 Prezel/feature/my/api/consumer-rules.pro create mode 100644 Prezel/feature/my/api/proguard-rules.pro create mode 100644 Prezel/feature/my/impl/consumer-rules.pro create mode 100644 Prezel/feature/my/impl/proguard-rules.pro diff --git a/Prezel/feature/my/api/consumer-rules.pro b/Prezel/feature/my/api/consumer-rules.pro new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Prezel/feature/my/api/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/Prezel/feature/my/api/proguard-rules.pro b/Prezel/feature/my/api/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/my/api/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 diff --git a/Prezel/feature/my/impl/consumer-rules.pro b/Prezel/feature/my/impl/consumer-rules.pro new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Prezel/feature/my/impl/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/Prezel/feature/my/impl/proguard-rules.pro b/Prezel/feature/my/impl/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/my/impl/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 From 5c290b3648c38981ffc5c000d686b5583b08af3f Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 18:14:04 +0900 Subject: [PATCH 08/34] =?UTF-8?q?chore:=20core/model=20=EB=B0=8F=20core/do?= =?UTF-8?q?main=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=B3=B4=EC=A1=B4=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20.gitkeep=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 디렉토리 구조를 유지하기 위해 `core:model` 및 `core:domain` 모듈의 테스트 경로에 `.gitkeep` 파일을 추가했습니다. --- .../domain/src/test/kotlin/com/team/prezel/core/domain/.gitkeep | 0 Prezel/core/model/src/test/java/com/team/prezel/core/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Prezel/core/domain/src/test/kotlin/com/team/prezel/core/domain/.gitkeep create mode 100644 Prezel/core/model/src/test/java/com/team/prezel/core/.gitkeep 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/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 From 762c5cb7d2bcacfa469cda1a1139ca1f524816bc Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 20:57:29 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat:=20User=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EA=B4=80=EB=A0=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core:model` 모듈에 사용자 정보를 관리하기 위한 `User` 데이터 모델을 추가했습니다. * `User`: id, email, nickname, profileImage, isRegistered 필드를 포함하는 사용자 데이터 클래스 정의 * `ProfileImage`: 이미지 URL 및 기본 이미지 여부(`isDefault`)를 포함하는 하위 데이터 클래스 정의 --- .../com/team/prezel/core/model/profile/User.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/profile/User.kt 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..19199688 --- /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: Nickname, + val profileImage: ProfileImage, + val isRegistered: Boolean, +) { + data class ProfileImage( + val url: String, + val isDefault: Boolean, + ) +} From e2838230f7605137cc066337596a6ceca6831fd9 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 20:57:40 +0900 Subject: [PATCH 10/34] =?UTF-8?q?feat:=20AdvancedImePadding=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20Modifier=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키보드가 올라올 때 레이아웃의 전역 위치를 계산하여 불필요한 공백 없이 IME 패딩을 적용하는 `advancedImePadding` 확장 함수를 추가했습니다. * `onGloballyPositioned`를 통해 루트 좌표계 대비 현재 컴포넌트의 하단 위치를 계산 * 계산된 하단 여백만큼 `consumeWindowInsets`를 적용하여 중복 패딩 방지 * `imePadding()`을 결합하여 키보드 활성화 시 동적인 패딩 조정 지원 --- .../team/prezel/core/ui/AdvancedImePadding.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/AdvancedImePadding.kt 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..713071ac --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/AdvancedImePadding.kt @@ -0,0 +1,26 @@ +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.positionInWindow +import androidx.compose.ui.platform.LocalDensity + +fun Modifier.advancedImePadding() = + composed { + var consumePadding by remember { mutableIntStateOf(0) } + onGloballyPositioned { coordinates -> + consumePadding = coordinates.findRootCoordinates().size.height - + (coordinates.positionInWindow().y + coordinates.size.height).toInt().coerceAtLeast(0) + }.consumeWindowInsets( + PaddingValues(bottom = with(LocalDensity.current) { consumePadding.toDp() }), + ).imePadding() + } From 57dd5def26be7388f3a1164de10d6b0f72545f5e Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 20:58:00 +0900 Subject: [PATCH 11/34] =?UTF-8?q?build:=20kotlinx-serialization=20?= =?UTF-8?q?=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `feature:login:impl` 모듈의 `build.gradle.kts`에 `kotlinx-serialization` 플러그인을 추가하였습니다. --- Prezel/feature/login/impl/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/Prezel/feature/login/impl/build.gradle.kts b/Prezel/feature/login/impl/build.gradle.kts index 764da8cd..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 { From 75f374c9e9eaf0fc121f00d628f259d6a6649b67 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 20:58:10 +0900 Subject: [PATCH 12/34] =?UTF-8?q?chore:=20MainActivity=20=EC=86=8C?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=ED=82=A4=EB=B3=B4=EB=93=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AndroidManifest.xml` 내 `MainActivity` 설정에 `android:windowSoftInputMode="adjustResize"` 속성을 추가하였습니다. 이를 통해 소프트 키보드가 나타날 때 레이아웃이 가려지지 않고 적절히 조정되도록 개선했습니다. --- Prezel/app/src/main/AndroidManifest.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"> From a871dd40edbde6bf092f0ce10cfe3abcd9358304 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 20:58:20 +0900 Subject: [PATCH 13/34] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 프로필 이미지 변경 및 사진 선택 기능 추가 프로필 화면에서 이미지를 변경하거나 기본 이미지로 초기화할 수 있는 기능을 구현했습니다. * `ProfileViewModel`: `OnProfileImageChanged` 인텐트 처리 로직 추가 및 이미지 URL에 따른 `isDefault` 상태 관리 * `ProfileScreen`: `ActivityResultContracts.PickVisualMedia`를 사용하여 시스템 사진 선택기 연동 * `Avatar`: 프로필 이미지 클릭 시 사진 선택기를 띄우거나 이미지를 제거(기본 이미지로 변경)하는 로직 구현 * 프로필 이미지 우측 하단에 상태에 따라 회전하는 추가/삭제 아이콘 버튼 적용 * refactor: 프로필 UI 상태 및 네비게이션 구조 개선 프로필 상태 관리 모델을 보완하고 관련 컴포넌트 구조를 정리했습니다. * `ProfileUiState`: `profileImage` 필드를 추가하고, 기존 `isPrimaryActionEnabled`를 `submitButtonEnabled`로 명칭 변경 * `ProfileNavKey.Edit`: 프로필 편집 시 기본 이미지 여부를 판단하기 위한 `isDefault` 파라미터 추가 * `ProfileEntryBuilder`: 네비게이션 시 전달받은 `profileUrl`과 `isDefault` 정보를 초기 상태에 반영하도록 수정 * `ProfileScreen`: 내부 레이아웃 로직을 `ProfileScreenContent`로 분리하여 가독성 개선 및 `advancedImePadding` 적용 * `PrezelTextField`: 키보드 타입을 `Text`로 명시하고 불필요한 공백 제거 로직 유지 * style: 문자열 리소스 추가 및 정리 * `feature_profile_impl_profile_image_content_description` 리소스 추가 * `strings.xml` 내 리소스 간격 조정 및 정렬 --- .../feature/profile/api/ProfileNavKey.kt | 1 + .../feature/profile/impl/ProfileScreen.kt | 159 +++++++++++++----- .../feature/profile/impl/ProfileViewModel.kt | 15 ++ .../profile/impl/contract/ProfileUiIntent.kt | 4 + .../profile/impl/contract/ProfileUiState.kt | 16 +- .../impl/navigation/ProfileEntryBuilder.kt | 9 +- .../impl/src/main/res/values/strings.xml | 2 + 7 files changed, 163 insertions(+), 43 deletions(-) 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 9d1d058f..9547347c 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 @@ -12,5 +12,6 @@ sealed interface ProfileNavKey : NavKey { data class Edit( val nickname: String, val profileUrl: String, + val isDefault: Boolean, ) : ProfileNavKey } 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 c92ab0cd..b80e9a6f 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,29 +1,41 @@ package com.team.prezel.feature.profile.impl + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions 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.draw.rotate import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.input.KeyboardType import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.PrezelAvatar import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +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.component.modal.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.component.textfield.PrezelTextField import com.team.prezel.core.designsystem.component.textfield.PrezelTextFieldFeedback +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.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.ProfileScreenTopAppBar import com.team.prezel.feature.profile.impl.contract.NicknameValidationState import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect @@ -41,6 +53,12 @@ internal fun ProfileScreen( 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.OnProfileImageChanged(profileUrl = uri.toString())) + } LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> @@ -61,7 +79,16 @@ internal fun ProfileScreen( onNicknameChanged = { nickname -> viewModel.onIntent(ProfileUiIntent.OnNicknameChanged(nickname.filterNot(Char::isWhitespace))) }, - onClickPrimaryAction = { viewModel.onIntent(ProfileUiIntent.OnClickSubmit) }, + onClickProfileImage = { + if (uiState.profileImage.isDefault) { + photoPickerLauncher.launch( + PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } else { + viewModel.onIntent(ProfileUiIntent.OnProfileImageChanged(profileUrl = "")) + } + }, + onClickSubmit = { viewModel.onIntent(ProfileUiIntent.OnClickSubmit) }, onBack = onBack, modifier = modifier, ) @@ -71,11 +98,11 @@ internal fun ProfileScreen( private fun ProfileScreen( uiState: ProfileUiState, onNicknameChanged: (String) -> Unit, - onClickPrimaryAction: () -> Unit, + onClickProfileImage: () -> Unit, + onClickSubmit: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { - val nicknameFeedback = uiState.nicknameValidation.toNicknameFeedback() val submitButtonText = stringResource(R.string.feature_profile_impl_submit_button_text) Column(modifier = modifier.fillMaxSize()) { @@ -84,47 +111,96 @@ private fun ProfileScreen( onBack = onBack, ) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = PrezelTheme.spacing.V20) - .padding(top = PrezelTheme.spacing.V16), - ) { - PrezelAvatar( - imageUrl = null, - contentDescription = "프로필 이미지", - modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(120.dp), - ) - - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) - - PrezelTextField( - value = uiState.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), - modifier = Modifier.fillMaxWidth(), - feedback = nicknameFeedback, - ) - - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - } + ProfileScreenContent( + profileUrl = uiState.profileImage.url, + isDefaultProfileImage = uiState.profileImage.isDefault, + nickname = uiState.nickname, + onNicknameChanged = onNicknameChanged, + nicknameFeedback = uiState.nicknameValidation.toNicknameFeedback(), + onClickProfileImage = onClickProfileImage, + modifier = Modifier.weight(1f), + ) PrezelButtonArea( showBackground = true, - modifier = Modifier.imePadding(), + modifier = Modifier.advancedImePadding(), ) { MainButton( label = submitButtonText, - enabled = uiState.isPrimaryActionEnabled, - onClick = onClickPrimaryAction, + enabled = uiState.submitButtonEnabled, + onClick = onClickSubmit, ) } } } +@Composable +private fun ProfileScreenContent( + profileUrl: String, + isDefaultProfileImage: Boolean, + nickname: String, + nicknameFeedback: PrezelTextFieldFeedback, + onNicknameChanged: (String) -> Unit, + onClickProfileImage: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(horizontal = PrezelTheme.spacing.V20) + .padding(top = PrezelTheme.spacing.V16), + ) { + Avatar( + profileUrl = profileUrl, + isDefaultProfileImage = isDefaultProfileImage, + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onClickProfileImage, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + 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), + modifier = Modifier.fillMaxWidth(), + feedback = nicknameFeedback, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + ), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + } +} + +@Composable +private fun Avatar( + 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, + ) + } +} + @Composable private fun NicknameValidationState.toNicknameFeedback(): PrezelTextFieldFeedback = when (this) { @@ -157,7 +233,8 @@ private fun CreateProfileScreenPreview() { ProfileScreen( uiState = ProfileUiState.Create(), onNicknameChanged = {}, - onClickPrimaryAction = {}, + onClickProfileImage = {}, + onClickSubmit = {}, onBack = {}, ) } @@ -168,9 +245,13 @@ private fun CreateProfileScreenPreview() { private fun EditProfileScreenPreview() { PrezelTheme { ProfileScreen( - uiState = ProfileUiState.Edit(originalNickname = ""), + uiState = ProfileUiState.Edit( + originalNickname = "", + profileImage = User.ProfileImage(url = "", isDefault = true), + ), onNicknameChanged = {}, - onClickPrimaryAction = {}, + onClickProfileImage = {}, + onClickSubmit = {}, onBack = {}, ) } 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 71798804..1e6b653d 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 @@ -3,6 +3,7 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.usecase.profile.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.NicknameValidationState import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect @@ -45,6 +46,7 @@ internal class ProfileViewModel @AssistedInject constructor( override fun onIntent(intent: ProfileUiIntent) { when (intent) { is ProfileUiIntent.OnNicknameChanged -> handleNicknameChanged(intent.nickname) + is ProfileUiIntent.OnProfileImageChanged -> handleProfileImageChanged(intent.profileUrl) ProfileUiIntent.OnClickSubmit -> submitProfile() } @@ -66,6 +68,19 @@ internal class ProfileViewModel @AssistedInject constructor( nicknameInput.value = sanitizedNickname } + private fun handleProfileImageChanged(profileUrl: String) { + if (profileUrl == currentState.profileImage.url) return + + updateState { + updateProfile( + profileImage = User.ProfileImage( + url = profileUrl, + isDefault = profileUrl.isBlank(), + ), + ) + } + } + private suspend fun validateNickname(nickname: String) { if (nickname.isBlank()) { updateState { updateProfile(nicknameValidation = NicknameValidationState.Unchecked) } 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 index 15743205..db8dc912 100644 --- 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 @@ -7,5 +7,9 @@ internal sealed interface ProfileUiIntent : UiIntent { val nickname: String, ) : ProfileUiIntent + data class OnProfileImageChanged( + val profileUrl: String, + ) : ProfileUiIntent + data object OnClickSubmit : 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 index e091d8e8..76557507 100644 --- 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 @@ -1,32 +1,39 @@ 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 @Immutable internal sealed interface ProfileUiState : UiState { val nickname: String val nicknameValidation: NicknameValidationState - val isPrimaryActionEnabled: Boolean + val profileImage: User.ProfileImage + + val submitButtonEnabled: Boolean fun updateProfile( nickname: String = this.nickname, nicknameValidation: NicknameValidationState = this.nicknameValidation, + profileImage: User.ProfileImage = this.profileImage, ): ProfileUiState data class Create( override val nickname: String = "", override val nicknameValidation: NicknameValidationState = NicknameValidationState.Unchecked, + override val profileImage: User.ProfileImage = User.ProfileImage(url = "", isDefault = true), ) : ProfileUiState { - override val isPrimaryActionEnabled: Boolean = nicknameValidation == NicknameValidationState.Available + override val submitButtonEnabled: Boolean = nicknameValidation == NicknameValidationState.Available override fun updateProfile( nickname: String, nicknameValidation: NicknameValidationState, + profileImage: User.ProfileImage, ): ProfileUiState = copy( nickname = nickname, nicknameValidation = nicknameValidation, + profileImage = profileImage, ) } @@ -34,17 +41,20 @@ internal sealed interface ProfileUiState : UiState { val originalNickname: String, override val nickname: String = originalNickname, override val nicknameValidation: NicknameValidationState = NicknameValidationState.Available, + override val profileImage: User.ProfileImage, ) : ProfileUiState { - override val isPrimaryActionEnabled: Boolean = + override val submitButtonEnabled: Boolean = nicknameValidation == NicknameValidationState.Available && nickname != originalNickname override fun updateProfile( nickname: String, nicknameValidation: NicknameValidationState, + profileImage: User.ProfileImage, ): ProfileUiState = copy( nickname = nickname, nicknameValidation = nicknameValidation, + profileImage = profileImage, ) } } 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 243ff292..da9eab4a 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 @@ -3,6 +3,7 @@ package com.team.prezel.feature.profile.impl.navigation import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.model.profile.User import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.profile.api.ProfileNavKey @@ -37,7 +38,13 @@ internal fun EntryProviderScope.featureProfileEntryBuilder() { viewModel = hiltViewModel( creationCallback = { factory -> factory.create( - initialState = ProfileUiState.Edit(originalNickname = key.nickname), + initialState = ProfileUiState.Edit( + originalNickname = key.nickname, + profileImage = User.ProfileImage( + url = key.profileUrl, + isDefault = key.isDefault, + ), + ), ) }, ), diff --git a/Prezel/feature/profile/impl/src/main/res/values/strings.xml b/Prezel/feature/profile/impl/src/main/res/values/strings.xml index 2d99b326..94c3e7b0 100644 --- a/Prezel/feature/profile/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -3,7 +3,9 @@ 프로필 편집 닉네임 완료 + 뒤로가기 + 프로필 닉네임을 입력해 주세요 From 1583300153053bb4d04a29f3a404f4e7f2bf820c Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:20:12 +0900 Subject: [PATCH 14/34] =?UTF-8?q?refactor:=20User=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=9D=98=20nickname=20=ED=83=80=EC=9E=85=EC=9D=84=20String?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `User` 데이터 모델 내 `nickname` 프로퍼티의 타입을 기존 커스텀 클래스인 `Nickname`에서 기본 타입인 `String`으로 변경하였습니다. --- .../src/main/java/com/team/prezel/core/model/profile/User.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 19199688..62c89f8d 100644 --- 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 @@ -3,7 +3,7 @@ package com.team.prezel.core.model.profile data class User( val id: Long, val email: String, - val nickname: Nickname, + val nickname: String, val profileImage: ProfileImage, val isRegistered: Boolean, ) { From 16d519c11a605f8296ec9cbc2b241da9230370ee Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:20:45 +0900 Subject: [PATCH 15/34] =?UTF-8?q?refactor:=20ValidateNicknameUseCase=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/user/ValidateNicknameUseCase.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/ValidateNicknameUseCase.kt 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..204cea71 --- /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.Companion.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 + } +} From c733cbfda1847a5ce03408d05a63be0de33f1f45 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:21:17 +0900 Subject: [PATCH 16/34] =?UTF-8?q?refactor:=20PrezelAsyncImage=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20Timber=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: PrezelAsyncImage 이미지 로드 실패 시 에러 로깅 로직 추가 이미지 로드 중 에러가 발생할 경우, 기존 `onError` 콜백 호출과 더불어 `Timber.e`를 통해 예외 상황을 로그로 기록하도록 개선하였습니다. * chore: core:designsystem 모듈에 Timber 의존성 추가 로깅 라이브러리 사용을 위해 `build.gradle.kts`에 `timber` 의존성을 추가하였습니다. --- Prezel/core/designsystem/build.gradle.kts | 1 + .../prezel/core/designsystem/component/PrezelAsyncImage.kt | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) 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, ) } From 6707481098c6febc5069be9b60c72c6fa20b7ba2 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:21:43 +0900 Subject: [PATCH 17/34] =?UTF-8?q?build:=20AndroidFeatureImplConventionPlug?= =?UTF-8?q?in=EC=97=90=20Timber=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: Feature 구현 모듈 공통 의존성에 Timber 추가 `AndroidFeatureImplConventionPlugin`에 `timber` 라이브러리 의존성을 추가하여, 해당 플러그인을 사용하는 모든 Feature 구현 모듈에서 로깅 라이브러리를 사용할 수 있도록 개선했습니다. --- .../convention/plugin/AndroidFeatureImplConventionPlugin.kt | 1 + 1 file changed, 1 insertion(+) 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()) } } } From 781c63ad4ec3a3b7864a41238db436a1c7ddeaa4 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:22:07 +0900 Subject: [PATCH 18/34] =?UTF-8?q?feat:=20FetchUserInfoUseCase=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20UserRepository=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유저 정보 조회를 위한 UseCase를 추가하고, 캐싱 로직이 포함된 Repository 구현체를 업데이트했습니다. --- .../data/repository/UserRepositoryImpl.kt | 18 +++++++ Prezel/core/domain/build.gradle.kts | 1 + .../repository/profile/UserRepository.kt | 3 ++ .../profile/ValidateNicknameUseCase.kt | 52 ------------------- .../usecase/user/FetchUserInfoUseCase.kt | 14 +++++ 5 files changed, 36 insertions(+), 52 deletions(-) delete mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/profile/ValidateNicknameUseCase.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserInfoUseCase.kt 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 index 7768d27c..6c55dc9e 100644 --- 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 @@ -2,10 +2,28 @@ 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 = "moon", + profileImage = User.ProfileImage(url = "https://picsum.photos/200", isDefault = false), + isRegistered = false, + ) + }.onSuccess { user -> + cachedUserInfo = user + } + override suspend fun checkNicknameDuplication(nickname: Nickname): Result { // TODO: 실제 API 연동 후 서버 중복 검사 결과를 반환하도록 교체 delay(200) diff --git a/Prezel/core/domain/build.gradle.kts b/Prezel/core/domain/build.gradle.kts index dd30f142..bfa0805f 100644 --- a/Prezel/core/domain/build.gradle.kts +++ b/Prezel/core/domain/build.gradle.kts @@ -5,4 +5,5 @@ plugins { 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 index 4a577336..a8d87443 100644 --- 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 @@ -1,7 +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/profile/ValidateNicknameUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/profile/ValidateNicknameUseCase.kt deleted file mode 100644 index cb559ad2..00000000 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/profile/ValidateNicknameUseCase.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.team.prezel.core.domain.usecase.profile - -import com.team.prezel.core.domain.repository.profile.UserRepository -import com.team.prezel.core.model.profile.Nickname -import javax.inject.Inject - -/** - * 닉네임 유효성 및 중복 여부를 검증하는 UseCase. - * - * ### 동작 흐름 - * 1. 입력된 문자열을 기반으로 [Nickname.create]를 호출하여 도메인 규칙에 맞는 닉네임인지 검증합니다. - * 2. 닉네임 생성에 성공한 경우, [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/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) +} From c6192ac0ccfcfbb1015215408fe7203e9c3e3f30 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:22:20 +0900 Subject: [PATCH 19/34] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/profile/api/ProfileNavKey.kt | 6 +- .../feature/profile/impl/ProfileScreen.kt | 23 ++++--- .../feature/profile/impl/ProfileViewModel.kt | 62 ++++++++++++------- .../profile/impl/contract/ProfileUiIntent.kt | 2 + .../profile/impl/contract/ProfileUiState.kt | 53 ++++++++++++---- .../profile/impl/model/ProfileUiMessage.kt | 1 + .../impl/navigation/ProfileEntryBuilder.kt | 22 +------ .../impl/src/main/res/values/strings.xml | 3 +- 8 files changed, 101 insertions(+), 71 deletions(-) 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 9547347c..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 @@ -9,9 +9,5 @@ sealed interface ProfileNavKey : NavKey { data object Create : ProfileNavKey @Serializable - data class Edit( - val nickname: String, - val profileUrl: String, - val isDefault: Boolean, - ) : ProfileNavKey + data object Edit : ProfileNavKey } 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 b80e9a6f..3ecb4479 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.PrezelAvatar import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea @@ -48,7 +49,7 @@ internal fun ProfileScreen( navigateToHome: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, - viewModel: ProfileViewModel, + viewModel: ProfileViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = LocalSnackbarHostState.current @@ -61,12 +62,15 @@ internal fun ProfileScreen( } LaunchedEffect(Unit) { + viewModel.onIntent(ProfileUiIntent.FetchData) + viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToHome -> navigateToHome() is ProfileUiEffect.ShowMessage -> { val resId = when (effect.message) { - ProfileUiMessage.CHECK_NICKNAME_FAILED -> R.string.feature_profile_impl_check_nickname_failed + 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)) } @@ -80,7 +84,7 @@ internal fun ProfileScreen( viewModel.onIntent(ProfileUiIntent.OnNicknameChanged(nickname.filterNot(Char::isWhitespace))) }, onClickProfileImage = { - if (uiState.profileImage.isDefault) { + if (uiState.canPhotoPickerLaunch()) { photoPickerLauncher.launch( PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly), ) @@ -103,6 +107,7 @@ private fun ProfileScreen( onBack: () -> Unit, modifier: Modifier = Modifier, ) { + val fetchedState = uiState as? ProfileUiState.Fetched val submitButtonText = stringResource(R.string.feature_profile_impl_submit_button_text) Column(modifier = modifier.fillMaxSize()) { @@ -112,11 +117,11 @@ private fun ProfileScreen( ) ProfileScreenContent( - profileUrl = uiState.profileImage.url, - isDefaultProfileImage = uiState.profileImage.isDefault, - nickname = uiState.nickname, + profileUrl = fetchedState?.profileImage?.url.orEmpty(), + isDefaultProfileImage = fetchedState?.profileImage?.isDefault ?: true, + nickname = fetchedState?.nickname.orEmpty(), onNicknameChanged = onNicknameChanged, - nicknameFeedback = uiState.nicknameValidation.toNicknameFeedback(), + nicknameFeedback = fetchedState?.nicknameValidation?.toNicknameFeedback() ?: PrezelTextFieldFeedback.NO_MESSAGE, onClickProfileImage = onClickProfileImage, modifier = Modifier.weight(1f), ) @@ -127,7 +132,7 @@ private fun ProfileScreen( ) { MainButton( label = submitButtonText, - enabled = uiState.submitButtonEnabled, + enabled = fetchedState?.submitButtonEnabled ?: false, onClick = onClickSubmit, ) } @@ -247,6 +252,8 @@ private fun EditProfileScreenPreview() { ProfileScreen( uiState = ProfileUiState.Edit( originalNickname = "", + nickname = "", + originalProfileImage = User.ProfileImage(url = "", isDefault = true), profileImage = User.ProfileImage(url = "", isDefault = true), ), onNicknameChanged = {}, 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 1e6b653d..650b1afe 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,7 +1,8 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.viewModelScope -import com.team.prezel.core.domain.usecase.profile.ValidateNicknameUseCase +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 @@ -9,34 +10,31 @@ import com.team.prezel.feature.profile.impl.contract.NicknameValidationState 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.Companion.toUiState import com.team.prezel.feature.profile.impl.model.ProfileUiMessage -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject 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(assistedFactory = ProfileViewModel.Factory::class) -internal class ProfileViewModel @AssistedInject constructor( - @Assisted initialState: ProfileUiState, +@HiltViewModel +internal class ProfileViewModel @Inject constructor( + private val fetchUserInfoUseCase: FetchUserInfoUseCase, private val validateNicknameUseCase: ValidateNicknameUseCase, -) : BaseViewModel(initialState) { - private val nicknameInput = MutableStateFlow(currentState.nickname) - - @AssistedFactory - interface Factory { - fun create(initialState: ProfileUiState): ProfileViewModel - } +) : BaseViewModel(ProfileUiState.Loading) { + private val nicknameChanges = MutableStateFlow(null) init { viewModelScope.launch { - nicknameInput + nicknameChanges + .filterNotNull() .debounce(NICKNAME_VALIDATION_DEBOUNCE_MILLIS) .distinctUntilChanged() .collectLatest(::validateNickname) @@ -45,34 +43,48 @@ internal class ProfileViewModel @AssistedInject constructor( override fun onIntent(intent: ProfileUiIntent) { when (intent) { + ProfileUiIntent.FetchData -> fetchUserInfo() is ProfileUiIntent.OnNicknameChanged -> handleNicknameChanged(intent.nickname) is ProfileUiIntent.OnProfileImageChanged -> handleProfileImageChanged(intent.profileUrl) - ProfileUiIntent.OnClickSubmit -> 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 state = currentState as? ProfileUiState.Fetched ?: return + val sanitizedNickname = nickname.take(Nickname.MAX_LENGTH) - if (sanitizedNickname == currentState.nickname) return + if (sanitizedNickname == state.nickname) return updateState { val validationState = if (sanitizedNickname.isBlank()) NicknameValidationState.Unchecked else NicknameValidationState.Checking - updateProfile( + state.updateProfile( nickname = sanitizedNickname, nicknameValidation = validationState, ) } - nicknameInput.value = sanitizedNickname + nicknameChanges.value = sanitizedNickname } private fun handleProfileImageChanged(profileUrl: String) { - if (profileUrl == currentState.profileImage.url) return + val state = currentState as? ProfileUiState.Fetched ?: return + if (profileUrl == state.profileImage.url) return updateState { - updateProfile( + state.updateProfile( profileImage = User.ProfileImage( url = profileUrl, isDefault = profileUrl.isBlank(), @@ -82,8 +94,9 @@ internal class ProfileViewModel @AssistedInject constructor( } private suspend fun validateNickname(nickname: String) { + val state = currentState as? ProfileUiState.Fetched ?: return if (nickname.isBlank()) { - updateState { updateProfile(nicknameValidation = NicknameValidationState.Unchecked) } + updateState { state.updateProfile(nicknameValidation = NicknameValidationState.Unchecked) } return } @@ -102,7 +115,7 @@ internal class ProfileViewModel @AssistedInject constructor( } } - updateState { updateProfile(nicknameValidation = validationState) } + updateState { state.updateProfile(nicknameValidation = validationState) } } private fun Nickname.InvalidReason.toValidationState(): NicknameValidationState = @@ -113,7 +126,8 @@ internal class ProfileViewModel @AssistedInject constructor( } private fun submitProfile() { - if (currentState.nicknameValidation != NicknameValidationState.Available) return + val state = currentState as? ProfileUiState.Fetched ?: return + if (state.nicknameValidation != NicknameValidationState.Available) return viewModelScope.launch { // todo: 닉네임 생성 API 호출 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 index db8dc912..9df9fe3d 100644 --- 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 @@ -3,6 +3,8 @@ 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 OnNicknameChanged( val nickname: String, ) : 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 index 76557507..585984ab 100644 --- 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 @@ -6,23 +6,33 @@ import com.team.prezel.core.ui.UiState @Immutable internal sealed interface ProfileUiState : UiState { - val nickname: String - val nicknameValidation: NicknameValidationState - val profileImage: User.ProfileImage + fun canPhotoPickerLaunch(): Boolean = + when (this) { + Loading -> false + is Fetched -> true + } - val submitButtonEnabled: Boolean + data object Loading : ProfileUiState - fun updateProfile( - nickname: String = this.nickname, - nicknameValidation: NicknameValidationState = this.nicknameValidation, - profileImage: User.ProfileImage = this.profileImage, - ): ProfileUiState + interface Fetched : ProfileUiState { + val nickname: String + val nicknameValidation: NicknameValidationState + val profileImage: User.ProfileImage + + val submitButtonEnabled: Boolean + + fun updateProfile( + nickname: String = this.nickname, + nicknameValidation: NicknameValidationState = this.nicknameValidation, + profileImage: User.ProfileImage = this.profileImage, + ): ProfileUiState + } data class Create( override val nickname: String = "", override val nicknameValidation: NicknameValidationState = NicknameValidationState.Unchecked, override val profileImage: User.ProfileImage = User.ProfileImage(url = "", isDefault = true), - ) : ProfileUiState { + ) : Fetched { override val submitButtonEnabled: Boolean = nicknameValidation == NicknameValidationState.Available override fun updateProfile( @@ -41,10 +51,12 @@ internal sealed interface ProfileUiState : UiState { val originalNickname: String, override val nickname: String = originalNickname, override val nicknameValidation: NicknameValidationState = NicknameValidationState.Available, + val originalProfileImage: User.ProfileImage, override val profileImage: User.ProfileImage, - ) : ProfileUiState { + ) : Fetched { override val submitButtonEnabled: Boolean = - nicknameValidation == NicknameValidationState.Available && nickname != originalNickname + (nicknameValidation == NicknameValidationState.Available && nickname != originalNickname) || + profileImage != originalProfileImage override fun updateProfile( nickname: String, @@ -57,6 +69,23 @@ internal sealed interface ProfileUiState : UiState { profileImage = profileImage, ) } + + companion object { + fun User.toUiState(): ProfileUiState = + if (profileImage.isDefault) { + Create( + nickname = nickname, + profileImage = profileImage, + ) + } else { + Edit( + originalNickname = nickname, + nickname = nickname, + originalProfileImage = profileImage, + profileImage = profileImage, + ) + } + } } internal enum class NicknameValidationState { 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 index f69ecb4c..150e2d83 100644 --- 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 @@ -2,4 +2,5 @@ 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 da9eab4a..a8092ccc 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 @@ -1,15 +1,11 @@ package com.team.prezel.feature.profile.impl.navigation -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import com.team.prezel.core.model.profile.User 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 com.team.prezel.feature.profile.impl.ProfileViewModel -import com.team.prezel.feature.profile.impl.contract.ProfileUiState import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,31 +19,15 @@ internal fun EntryProviderScope.featureProfileEntryBuilder() { ProfileScreen( navigateToHome = { navigator.replaceRoot(HomeNavKey) }, onBack = { navigator.goBack() }, - viewModel = hiltViewModel( - creationCallback = { factory -> factory.create(initialState = ProfileUiState.Create()) }, - ), ) } - entry { key -> + entry { val navigator = LocalNavigator.current ProfileScreen( navigateToHome = { navigator.replaceRoot(HomeNavKey) }, onBack = { navigator.goBack() }, - viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create( - initialState = ProfileUiState.Edit( - originalNickname = key.nickname, - profileImage = User.ProfileImage( - url = key.profileUrl, - isDefault = key.isDefault, - ), - ), - ) - }, - ), ) } } diff --git a/Prezel/feature/profile/impl/src/main/res/values/strings.xml b/Prezel/feature/profile/impl/src/main/res/values/strings.xml index 94c3e7b0..4dee37eb 100644 --- a/Prezel/feature/profile/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -15,5 +15,6 @@ 사용할 수 없는 닉네임이에요. - 닉네임 중복 확인에 실패했어요. 잠시 후 다시 시도해 주세요. + 닉네임 중복 확인에 실패했어요. + 유저 정보 조회에 실패했어요. From 5c7bfb8a4bb98035860407a66638c7b62142bdbc Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:28:54 +0900 Subject: [PATCH 20/34] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=A0=9C=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=EB=B0=8F=20UI=20Effect=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: ProfileViewModel 내 프로필 제출 활성화 조건 변경** * `submitProfile` 함수에서 버튼 활성화 여부를 단순히 닉네임 유효성 검사 결과(`NicknameValidationState.Available`)만 체크하던 방식에서, 상태 객체의 `submitButtonEnabled` 프로퍼티를 확인하도록 수정했습니다. * 프로필 수정 API 호출을 위한 주석 처리된 가이드 코드를 추가했습니다. * **feat: ProfileUiEffect 내 뒤로가기 액션 추가 및 처리** * `ProfileUiEffect` 인터페이스에 `OnBack` 오브젝트를 추가했습니다. * `ProfileScreen`에서 `OnBack` 이펙트 수신 시 `onBack()` 콜백을 호출하도록 처리 로직을 추가했습니다. --- .../feature/profile/impl/ProfileScreen.kt | 1 + .../feature/profile/impl/ProfileViewModel.kt | 17 ++++++++++++++--- .../profile/impl/contract/ProfileUiEffect.kt | 2 ++ 3 files changed, 17 insertions(+), 3 deletions(-) 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 3ecb4479..a78c79d7 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 @@ -67,6 +67,7 @@ internal fun ProfileScreen( viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToHome -> navigateToHome() + ProfileUiEffect.OnBack -> onBack() is ProfileUiEffect.ShowMessage -> { val resId = when (effect.message) { ProfileUiMessage.CHECK_NICKNAME_FAILED -> R.string.feature_profile_impl_check_nickname_failed_message 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 650b1afe..62c830f3 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 @@ -126,11 +126,22 @@ internal class ProfileViewModel @Inject constructor( } private fun submitProfile() { - val state = currentState as? ProfileUiState.Fetched ?: return - if (state.nicknameValidation != NicknameValidationState.Available) return + val fetchedState = currentState as? ProfileUiState.Fetched ?: return + if (!fetchedState.submitButtonEnabled) return viewModelScope.launch { - // todo: 닉네임 생성 API 호출 + // 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) } } 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 index fcf4944e..e891c0bc 100644 --- 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 @@ -6,6 +6,8 @@ import com.team.prezel.feature.profile.impl.model.ProfileUiMessage internal sealed interface ProfileUiEffect : UiEffect { data object NavigateToHome : ProfileUiEffect + data object OnBack : ProfileUiEffect + data class ShowMessage( val message: ProfileUiMessage, ) : ProfileUiEffect From 1568e5cb787e66a806fd3422f7c39c365c4e613a Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:29:56 +0900 Subject: [PATCH 21/34] =?UTF-8?q?refactor:=20UserRepositoryImpl=20?= =?UTF-8?q?=EB=82=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/prezel/core/data/repository/UserRepositoryImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 6c55dc9e..21982145 100644 --- 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 @@ -16,8 +16,8 @@ internal class UserRepositoryImpl @Inject constructor() : UserRepository { User( id = 1, email = "test@gmail.com", - nickname = "moon", - profileImage = User.ProfileImage(url = "https://picsum.photos/200", isDefault = false), + nickname = "", + profileImage = User.ProfileImage(url = "https://picsum.photos/200", isDefault = true), isRegistered = false, ) }.onSuccess { user -> From 2a49c0ce08fd91d26bffd9b33f0fe59224d3ba0e Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:44:10 +0900 Subject: [PATCH 22/34] =?UTF-8?q?refactor:=20ProfileScreen=20UI=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProfileScreen`의 가독성과 유지보수성을 높이기 위해 내부 UI 로직을 별도 컴포저블로 분리하고 코드 구조를 정리했습니다. --- .../feature/profile/impl/ProfileScreen.kt | 94 +++---------------- .../impl/component/NicknameTextField.kt | 76 +++++++++++++++ .../impl/component/ProfileImageEditor.kt | 71 ++++++++++++++ 3 files changed, 159 insertions(+), 82 deletions(-) create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/NicknameTextField.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/component/ProfileImageEditor.kt 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 a78c79d7..6220fc8f 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 @@ -3,40 +3,29 @@ package com.team.prezel.feature.profile.impl import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions 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.draw.rotate import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.team.prezel.core.designsystem.component.PrezelAvatar import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea -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.component.modal.snackbar.showPrezelSnackbar -import com.team.prezel.core.designsystem.component.textfield.PrezelTextField -import com.team.prezel.core.designsystem.component.textfield.PrezelTextFieldFeedback -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.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.NicknameValidationState import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect @@ -89,9 +78,10 @@ internal fun ProfileScreen( photoPickerLauncher.launch( PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly), ) - } else { - viewModel.onIntent(ProfileUiIntent.OnProfileImageChanged(profileUrl = "")) + return@ProfileScreen } + + viewModel.onIntent(ProfileUiIntent.OnProfileImageChanged(profileUrl = "")) }, onClickSubmit = { viewModel.onIntent(ProfileUiIntent.OnClickSubmit) }, onBack = onBack, @@ -122,7 +112,7 @@ private fun ProfileScreen( isDefaultProfileImage = fetchedState?.profileImage?.isDefault ?: true, nickname = fetchedState?.nickname.orEmpty(), onNicknameChanged = onNicknameChanged, - nicknameFeedback = fetchedState?.nicknameValidation?.toNicknameFeedback() ?: PrezelTextFieldFeedback.NO_MESSAGE, + nicknameValidationState = fetchedState?.nicknameValidation ?: NicknameValidationState.Unchecked, onClickProfileImage = onClickProfileImage, modifier = Modifier.weight(1f), ) @@ -145,7 +135,7 @@ private fun ProfileScreenContent( profileUrl: String, isDefaultProfileImage: Boolean, nickname: String, - nicknameFeedback: PrezelTextFieldFeedback, + nicknameValidationState: NicknameValidationState, onNicknameChanged: (String) -> Unit, onClickProfileImage: () -> Unit, modifier: Modifier = Modifier, @@ -155,7 +145,7 @@ private fun ProfileScreenContent( .padding(horizontal = PrezelTheme.spacing.V20) .padding(top = PrezelTheme.spacing.V16), ) { - Avatar( + ProfileImageEditor( profileUrl = profileUrl, isDefaultProfileImage = isDefaultProfileImage, modifier = Modifier.align(Alignment.CenterHorizontally), @@ -164,74 +154,14 @@ private fun ProfileScreenContent( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) - 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), - modifier = Modifier.fillMaxWidth(), - feedback = nicknameFeedback, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - ), - ) - - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - } -} - -@Composable -private fun Avatar( - 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, + NicknameTextField( + nickname = nickname, + onNicknameChanged = onNicknameChanged, + nicknameValidationState = nicknameValidationState, ) } } -@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 CreateProfileScreenPreview() { 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..7ab524c4 --- /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.contract.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 = {}, + ) + } + } +} From fb91880f31765f0237b4c79ba3fc9dfe6984354e Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 22:46:26 +0900 Subject: [PATCH 23/34] =?UTF-8?q?chore:=20`UserRepositoryImpl`=20=EB=82=B4?= =?UTF-8?q?=20TODO=20=EC=A3=BC=EC=84=9D=EC=9D=98=20=EB=8C=80=EC=86=8C?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkNicknameDuplication` 메서드 내의 `TODO` 주석을 `todo`로 변경하였습니다. --- .../com/team/prezel/core/data/repository/UserRepositoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 21982145..168c4041 100644 --- 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 @@ -25,7 +25,7 @@ internal class UserRepositoryImpl @Inject constructor() : UserRepository { } override suspend fun checkNicknameDuplication(nickname: Nickname): Result { - // TODO: 실제 API 연동 후 서버 중복 검사 결과를 반환하도록 교체 + // todo: 실제 API 연동 후 서버 중복 검사 결과를 반환하도록 교체 delay(200) return Result.success(false) } From af5d6d2041dc60560948a44ea7cad14905d1c30f Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 23:05:03 +0900 Subject: [PATCH 24/34] =?UTF-8?q?refactor:=20ProfileViewModel=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B3=B5=EB=B0=B1=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prezel/feature/profile/impl/ProfileScreen.kt | 4 +--- .../feature/profile/impl/ProfileViewModel.kt | 15 ++++++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) 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 6220fc8f..d68c00c5 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 @@ -70,9 +70,7 @@ internal fun ProfileScreen( ProfileScreen( uiState = uiState, - onNicknameChanged = { nickname -> - viewModel.onIntent(ProfileUiIntent.OnNicknameChanged(nickname.filterNot(Char::isWhitespace))) - }, + onNicknameChanged = { nickname -> viewModel.onIntent(ProfileUiIntent.OnNicknameChanged(nickname)) }, onClickProfileImage = { if (uiState.canPhotoPickerLaunch()) { photoPickerLauncher.launch( 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 62c830f3..86786a9b 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 @@ -62,15 +62,17 @@ internal class ProfileViewModel @Inject constructor( } private fun handleNicknameChanged(nickname: String) { - val state = currentState as? ProfileUiState.Fetched ?: return + val fetchedState = currentState as? ProfileUiState.Fetched ?: return - val sanitizedNickname = nickname.take(Nickname.MAX_LENGTH) - if (sanitizedNickname == state.nickname) return + val sanitizedNickname = nickname + .filterNot(Char::isWhitespace) + .take(Nickname.MAX_LENGTH) + if (sanitizedNickname == fetchedState.nickname) return updateState { val validationState = if (sanitizedNickname.isBlank()) NicknameValidationState.Unchecked else NicknameValidationState.Checking - state.updateProfile( + fetchedState.updateProfile( nickname = sanitizedNickname, nicknameValidation = validationState, ) @@ -115,7 +117,10 @@ internal class ProfileViewModel @Inject constructor( } } - updateState { state.updateProfile(nicknameValidation = validationState) } + updateState { + val fetchedState = currentState as? ProfileUiState.Fetched ?: return@updateState currentState + fetchedState.updateProfile(nicknameValidation = validationState) + } } private fun Nickname.InvalidReason.toValidationState(): NicknameValidationState = From ea4174d4b9db45e65c230b9c9959cdf72793d966 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 23:11:36 +0900 Subject: [PATCH 25/34] =?UTF-8?q?refactor:=20ProfileUiState=EC=9D=98=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=84=A0=ED=83=9D=20=EA=B0=80=EB=8A=A5=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=EC=86=8D=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProfileUiState`의 상태 판단 로직을 구체화하고, 인터페이스 상속 구조를 정리하여 코드의 일관성을 높였습니다. --- .../profile/impl/contract/ProfileUiState.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 index 585984ab..5f75ed74 100644 --- 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 @@ -6,15 +6,11 @@ import com.team.prezel.core.ui.UiState @Immutable internal sealed interface ProfileUiState : UiState { - fun canPhotoPickerLaunch(): Boolean = - when (this) { - Loading -> false - is Fetched -> true - } + fun canPhotoPickerLaunch(): Boolean = (this as? Fetched)?.profileImage?.isDefault == true data object Loading : ProfileUiState - interface Fetched : ProfileUiState { + interface Fetched { val nickname: String val nicknameValidation: NicknameValidationState val profileImage: User.ProfileImage @@ -32,7 +28,8 @@ internal sealed interface ProfileUiState : UiState { override val nickname: String = "", override val nicknameValidation: NicknameValidationState = NicknameValidationState.Unchecked, override val profileImage: User.ProfileImage = User.ProfileImage(url = "", isDefault = true), - ) : Fetched { + ) : Fetched, + ProfileUiState { override val submitButtonEnabled: Boolean = nicknameValidation == NicknameValidationState.Available override fun updateProfile( @@ -53,7 +50,8 @@ internal sealed interface ProfileUiState : UiState { override val nicknameValidation: NicknameValidationState = NicknameValidationState.Available, val originalProfileImage: User.ProfileImage, override val profileImage: User.ProfileImage, - ) : Fetched { + ) : Fetched, + ProfileUiState { override val submitButtonEnabled: Boolean = (nicknameValidation == NicknameValidationState.Available && nickname != originalNickname) || profileImage != originalProfileImage From 6a4bf1375ddeeac9a82efb0e97584643b2f18975 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 23:18:17 +0900 Subject: [PATCH 26/34] =?UTF-8?q?refactor:=20ProfileViewModel=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닉네임 입력 상태에 따른 검증 상태(NicknameValidationState) 할당 로직을 세분화하고 불필요한 체크를 제거했습니다. * `onNicknameChanged`: 닉네임이 비어있는 경우, 기존 닉네임 유무에 따라 `TooShort` 또는 `Unchecked` 상태를 할당하도록 수정했습니다. * `validateNickname`: 메서드 진입 시 닉네임 빈 값 체크를 단순화하고 중복된 상태 업데이트 로직을 제거했습니다. --- .../prezel/feature/profile/impl/ProfileViewModel.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 86786a9b..3a7621bb 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 @@ -70,7 +70,11 @@ internal class ProfileViewModel @Inject constructor( if (sanitizedNickname == fetchedState.nickname) return updateState { - val validationState = if (sanitizedNickname.isBlank()) NicknameValidationState.Unchecked else NicknameValidationState.Checking + val validationState = when { + sanitizedNickname.isBlank() && fetchedState.nickname.isNotBlank() -> NicknameValidationState.TooShort + sanitizedNickname.isBlank() -> NicknameValidationState.Unchecked + else -> NicknameValidationState.Checking + } fetchedState.updateProfile( nickname = sanitizedNickname, @@ -96,11 +100,7 @@ internal class ProfileViewModel @Inject constructor( } private suspend fun validateNickname(nickname: String) { - val state = currentState as? ProfileUiState.Fetched ?: return - if (nickname.isBlank()) { - updateState { state.updateProfile(nicknameValidation = NicknameValidationState.Unchecked) } - return - } + if (nickname.isBlank()) return val validationState = when (val result = validateNicknameUseCase(nickname)) { is ValidateNicknameUseCase.Result.Available -> NicknameValidationState.Available From b83b02dfb9772eb374cdd2f511c95ae869a0e89c Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 16 Apr 2026 23:59:34 +0900 Subject: [PATCH 27/34] =?UTF-8?q?refactor:=20AdvancedImePadding=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `advancedImePadding` 수정자 내에서 컴포넌트의 하단 위치를 계산할 때의 기준 좌표와 수치 변환 방식을 수정하였습니다. * `positionInWindow()` 대신 `positionInRoot()`를 사용하여 윈도우 기준이 아닌 루트 레이아웃 기준으로 좌표를 계산하도록 변경했습니다. * 좌표 값 변환 시 `toInt()` 대신 `roundToInt()`를 적용하여 정확도를 개선하고 불필요한 `coerceAtLeast(0)` 호출을 정리했습니다. --- .../main/java/com/team/prezel/core/ui/AdvancedImePadding.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 713071ac..41336fcf 100644 --- 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 @@ -11,15 +11,16 @@ 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.positionInWindow +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.positionInWindow().y + coordinates.size.height).toInt().coerceAtLeast(0) + (coordinates.positionInRoot().y + coordinates.size.height).roundToInt() }.consumeWindowInsets( PaddingValues(bottom = with(LocalDensity.current) { consumePadding.toDp() }), ).imePadding() From 700cb82d1a020775dc9da87ec6a1fff5611282bf Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 17 Apr 2026 00:03:08 +0900 Subject: [PATCH 28/34] =?UTF-8?q?refactor:=20ProfileUiState=20=EB=82=B4=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EB=B2=84=ED=8A=BC=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProfileUiState.Fetched` 상태에서 프로필 수정 버튼(`submitButtonEnabled`)이 활성화되는 조건을 더 엄격하게 개선했습니다. * 닉네임의 유효성 상태(`nicknameValidation`)가 `Available`인 경우를 필수 조건으로 설정하였습니다. * 닉네임 또는 프로필 이미지 중 최소 하나라도 변경되었을 때만 버튼이 활성화되도록 로직을 수정했습니다. (기존에는 닉네임이 유효하지 않은 상태여도 프로필 이미지만 변경되면 버튼이 활성화될 수 있었던 문제를 해결) --- .../prezel/feature/profile/impl/contract/ProfileUiState.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 5f75ed74..1a656e18 100644 --- 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 @@ -53,8 +53,8 @@ internal sealed interface ProfileUiState : UiState { ) : Fetched, ProfileUiState { override val submitButtonEnabled: Boolean = - (nicknameValidation == NicknameValidationState.Available && nickname != originalNickname) || - profileImage != originalProfileImage + nicknameValidation == NicknameValidationState.Available && + (nickname != originalNickname || profileImage != originalProfileImage) override fun updateProfile( nickname: String, From e4167ff11233d45b1e786d7e00a096bd6bfee253 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 17 Apr 2026 00:04:55 +0900 Subject: [PATCH 29/34] =?UTF-8?q?fix:=20ProfileViewModel=20=EB=82=B4=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EA=B2=B0=EA=B3=BC=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `updateNicknameValidation` 함수에서 유효성 검사 결과가 현재 입력된 닉네임과 일치할 때만 상태를 업데이트하도록 방어 로직을 추가했습니다. 비동기 처리 과정에서 발생할 수 있는 상태 불일치 문제를 방지합니다. --- .../com/team/prezel/feature/profile/impl/ProfileViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 3a7621bb..61dce121 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 @@ -119,6 +119,7 @@ internal class ProfileViewModel @Inject constructor( updateState { val fetchedState = currentState as? ProfileUiState.Fetched ?: return@updateState currentState + if (fetchedState.nickname != nickname) return@updateState currentState fetchedState.updateProfile(nicknameValidation = validationState) } } From c539b69076287493e5b458e640643f8b039b45ab Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 17 Apr 2026 00:37:40 +0900 Subject: [PATCH 30/34] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95(ProfileUiState)=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProfileUiState`의 복잡한 인터페이스 구조를 단순화하고, 관련 도메인 모델의 위치를 재정의하여 가독성과 유지보수성을 높였습니다. * **refactor: ProfileUiState 구조 단순화 및 통합** * 기존 `Fetched`, `Create`, `Edit`으로 나뉘어 있던 상태 구조를 `ProfileUiState.Content` 단일 데이터 클래스로 통합했습니다. * `isNewProfile` 여부를 상태 클래스 내부가 아닌 `ProfileScreen` 파라미터를 통해 외부에서 주입받도록 변경했습니다. * `canPhotoPickerLaunch()` 함수를 `shouldLaunchPhotoPicker` 프로퍼티로 변경하고 로직을 정리했습니다. * `User.toUiState()` 확장 함수 내에서 사용자 등록 여부(`isRegistered`)에 따라 초기 닉네임 검증 상태를 설정하도록 수정했습니다. * **refactor: NicknameValidationState 위치 변경** * `ProfileUiState.kt` 내부에 정의되어 있던 `NicknameValidationState` enum 클래스를 별도 파일(`model/NicknameValidationState.kt`)로 분리했습니다. * **refactor: ProfileViewModel 로직 수정** * 변경된 `ProfileUiState.Content` 구조에 맞춰 `handleNicknameChanged`, `handleProfileImageChanged` 등의 상태 업데이트 로직을 `copy` 기반으로 간소화했습니다. * **style: ProfileScreen 및 Preview 코드 정리** * `ProfileScreen` 컴포저블에 `isNewProfile` 파라미터를 추가하여 상단 바 UI(`isCreate`) 결정 로직을 분리했습니다. * 중복되던 `EditProfileScreenPreview`를 제거하고 통합된 상태를 사용하는 `CreateProfileScreenPreview`로 정리했습니다. --- .../feature/profile/impl/ProfileScreen.kt | 41 ++++---- .../feature/profile/impl/ProfileViewModel.kt | 28 +++--- .../profile/impl/contract/ProfileUiState.kt | 93 ++++--------------- .../impl/model/NicknameValidationState.kt | 11 +++ .../impl/navigation/ProfileEntryBuilder.kt | 2 + 5 files changed, 59 insertions(+), 116 deletions(-) create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/NicknameValidationState.kt 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 d68c00c5..2f11a129 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 @@ -27,14 +27,15 @@ 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.NicknameValidationState 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 internal fun ProfileScreen( + isNewProfile: Boolean, navigateToHome: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -70,9 +71,10 @@ internal fun ProfileScreen( ProfileScreen( uiState = uiState, + isNewProfile = isNewProfile, onNicknameChanged = { nickname -> viewModel.onIntent(ProfileUiIntent.OnNicknameChanged(nickname)) }, onClickProfileImage = { - if (uiState.canPhotoPickerLaunch()) { + if (uiState.shouldLaunchPhotoPicker) { photoPickerLauncher.launch( PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly), ) @@ -90,27 +92,28 @@ internal fun ProfileScreen( @Composable private fun ProfileScreen( uiState: ProfileUiState, + isNewProfile: Boolean, onNicknameChanged: (String) -> Unit, onClickProfileImage: () -> Unit, onClickSubmit: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { - val fetchedState = uiState as? ProfileUiState.Fetched + val contentState = uiState as? ProfileUiState.Content val submitButtonText = stringResource(R.string.feature_profile_impl_submit_button_text) Column(modifier = modifier.fillMaxSize()) { ProfileScreenTopAppBar( - isCreate = uiState is ProfileUiState.Create, + isCreate = isNewProfile, onBack = onBack, ) ProfileScreenContent( - profileUrl = fetchedState?.profileImage?.url.orEmpty(), - isDefaultProfileImage = fetchedState?.profileImage?.isDefault ?: true, - nickname = fetchedState?.nickname.orEmpty(), + profileUrl = contentState?.profileImage?.url.orEmpty(), + isDefaultProfileImage = contentState?.profileImage?.isDefault ?: true, + nickname = contentState?.nickname.orEmpty(), onNicknameChanged = onNicknameChanged, - nicknameValidationState = fetchedState?.nicknameValidation ?: NicknameValidationState.Unchecked, + nicknameValidationState = contentState?.nicknameValidation ?: NicknameValidationState.Unchecked, onClickProfileImage = onClickProfileImage, modifier = Modifier.weight(1f), ) @@ -121,7 +124,7 @@ private fun ProfileScreen( ) { MainButton( label = submitButtonText, - enabled = fetchedState?.submitButtonEnabled ?: false, + enabled = contentState?.submitButtonEnabled ?: false, onClick = onClickSubmit, ) } @@ -165,26 +168,14 @@ private fun ProfileScreenContent( private fun CreateProfileScreenPreview() { PrezelTheme { ProfileScreen( - uiState = ProfileUiState.Create(), - onNicknameChanged = {}, - onClickProfileImage = {}, - onClickSubmit = {}, - onBack = {}, - ) - } -} - -@BasicPreview -@Composable -private fun EditProfileScreenPreview() { - PrezelTheme { - ProfileScreen( - uiState = ProfileUiState.Edit( + uiState = ProfileUiState.Content( originalNickname = "", - nickname = "", originalProfileImage = User.ProfileImage(url = "", isDefault = true), + nickname = "", + nicknameValidation = NicknameValidationState.Unchecked, profileImage = User.ProfileImage(url = "", isDefault = true), ), + isNewProfile = true, onNicknameChanged = {}, onClickProfileImage = {}, onClickSubmit = {}, 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 61dce121..08eff32f 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 @@ -6,11 +6,11 @@ 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.NicknameValidationState 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.Companion.toUiState +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 @@ -62,21 +62,21 @@ internal class ProfileViewModel @Inject constructor( } private fun handleNicknameChanged(nickname: String) { - val fetchedState = currentState as? ProfileUiState.Fetched ?: return + val uiState = currentState as? ProfileUiState.Content ?: return val sanitizedNickname = nickname .filterNot(Char::isWhitespace) .take(Nickname.MAX_LENGTH) - if (sanitizedNickname == fetchedState.nickname) return + if (sanitizedNickname == uiState.nickname) return updateState { val validationState = when { - sanitizedNickname.isBlank() && fetchedState.nickname.isNotBlank() -> NicknameValidationState.TooShort + sanitizedNickname.isBlank() && uiState.nickname.isNotBlank() -> NicknameValidationState.TooShort sanitizedNickname.isBlank() -> NicknameValidationState.Unchecked else -> NicknameValidationState.Checking } - fetchedState.updateProfile( + uiState.copy( nickname = sanitizedNickname, nicknameValidation = validationState, ) @@ -86,11 +86,11 @@ internal class ProfileViewModel @Inject constructor( } private fun handleProfileImageChanged(profileUrl: String) { - val state = currentState as? ProfileUiState.Fetched ?: return - if (profileUrl == state.profileImage.url) return + val uiState = currentState as? ProfileUiState.Content ?: return + if (profileUrl == uiState.profileImage.url) return updateState { - state.updateProfile( + uiState.copy( profileImage = User.ProfileImage( url = profileUrl, isDefault = profileUrl.isBlank(), @@ -118,9 +118,9 @@ internal class ProfileViewModel @Inject constructor( } updateState { - val fetchedState = currentState as? ProfileUiState.Fetched ?: return@updateState currentState - if (fetchedState.nickname != nickname) return@updateState currentState - fetchedState.updateProfile(nicknameValidation = validationState) + val uiState = currentState as? ProfileUiState.Content ?: return@updateState currentState + if (uiState.nickname != nickname) return@updateState currentState + uiState.copy(nicknameValidation = validationState) } } @@ -132,8 +132,8 @@ internal class ProfileViewModel @Inject constructor( } private fun submitProfile() { - val fetchedState = currentState as? ProfileUiState.Fetched ?: return - if (!fetchedState.submitButtonEnabled) return + val uiState = currentState as? ProfileUiState.Content ?: return + if (!uiState.submitButtonEnabled) return viewModelScope.launch { // todo: 프로필 수정 API 호출 필요 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 index 1a656e18..7f34cf00 100644 --- 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 @@ -3,95 +3,34 @@ 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 { - fun canPhotoPickerLaunch(): Boolean = (this as? Fetched)?.profileImage?.isDefault == true + val shouldLaunchPhotoPicker get(): Boolean = (this as? Content)?.profileImage?.isDefault == true data object Loading : ProfileUiState - interface Fetched { - val nickname: String - val nicknameValidation: NicknameValidationState - val profileImage: User.ProfileImage - - val submitButtonEnabled: Boolean - - fun updateProfile( - nickname: String = this.nickname, - nicknameValidation: NicknameValidationState = this.nicknameValidation, - profileImage: User.ProfileImage = this.profileImage, - ): ProfileUiState - } - - data class Create( - override val nickname: String = "", - override val nicknameValidation: NicknameValidationState = NicknameValidationState.Unchecked, - override val profileImage: User.ProfileImage = User.ProfileImage(url = "", isDefault = true), - ) : Fetched, - ProfileUiState { - override val submitButtonEnabled: Boolean = nicknameValidation == NicknameValidationState.Available - - override fun updateProfile( - nickname: String, - nicknameValidation: NicknameValidationState, - profileImage: User.ProfileImage, - ): ProfileUiState = - copy( - nickname = nickname, - nicknameValidation = nicknameValidation, - profileImage = profileImage, - ) - } - - data class Edit( - val originalNickname: String, - override val nickname: String = originalNickname, - override val nicknameValidation: NicknameValidationState = NicknameValidationState.Available, - val originalProfileImage: User.ProfileImage, - override val profileImage: User.ProfileImage, - ) : Fetched, - ProfileUiState { - override val submitButtonEnabled: Boolean = + 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) - override fun updateProfile( - nickname: String, - nicknameValidation: NicknameValidationState, - profileImage: User.ProfileImage, - ): ProfileUiState = - copy( - nickname = nickname, - nicknameValidation = nicknameValidation, - profileImage = profileImage, - ) - } - - companion object { - fun User.toUiState(): ProfileUiState = - if (profileImage.isDefault) { - Create( - nickname = nickname, - profileImage = profileImage, - ) - } else { - Edit( + companion object { + fun User.toUiState(): ProfileUiState = + Content( originalNickname = nickname, - nickname = nickname, originalProfileImage = profileImage, + nickname = nickname, + nicknameValidation = if (isRegistered) NicknameValidationState.Available else NicknameValidationState.Unchecked, profileImage = profileImage, ) - } + } } } - -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/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/navigation/ProfileEntryBuilder.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/navigation/ProfileEntryBuilder.kt index a8092ccc..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 @@ -17,6 +17,7 @@ internal fun EntryProviderScope.featureProfileEntryBuilder() { val navigator = LocalNavigator.current ProfileScreen( + isNewProfile = true, navigateToHome = { navigator.replaceRoot(HomeNavKey) }, onBack = { navigator.goBack() }, ) @@ -26,6 +27,7 @@ internal fun EntryProviderScope.featureProfileEntryBuilder() { val navigator = LocalNavigator.current ProfileScreen( + isNewProfile = false, navigateToHome = { navigator.replaceRoot(HomeNavKey) }, onBack = { navigator.goBack() }, ) From 0f8d5dcee4a1ced4fa680822fa692c79b418a694 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 17 Apr 2026 00:52:24 +0900 Subject: [PATCH 31/34] =?UTF-8?q?refactor:=20Profile=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20Effect=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/team/prezel/feature/profile/impl/ProfileScreen.kt | 2 +- .../prezel/feature/profile/impl/component/NicknameTextField.kt | 2 +- .../prezel/feature/profile/impl/contract/ProfileUiEffect.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 2f11a129..d083275f 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 @@ -57,7 +57,7 @@ internal fun ProfileScreen( viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToHome -> navigateToHome() - ProfileUiEffect.OnBack -> onBack() + ProfileUiEffect.NavigateToBack -> onBack() is ProfileUiEffect.ShowMessage -> { val resId = when (effect.message) { ProfileUiMessage.CHECK_NICKNAME_FAILED -> R.string.feature_profile_impl_check_nickname_failed_message 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 index 7ab524c4..59d67e7f 100644 --- 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 @@ -14,7 +14,7 @@ import com.team.prezel.core.designsystem.component.textfield.PrezelTextFieldFeed 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.contract.NicknameValidationState +import com.team.prezel.feature.profile.impl.model.NicknameValidationState @Composable internal fun NicknameTextField( 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 index e891c0bc..5272c49e 100644 --- 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 @@ -6,7 +6,7 @@ import com.team.prezel.feature.profile.impl.model.ProfileUiMessage internal sealed interface ProfileUiEffect : UiEffect { data object NavigateToHome : ProfileUiEffect - data object OnBack : ProfileUiEffect + data object NavigateToBack : ProfileUiEffect data class ShowMessage( val message: ProfileUiMessage, From ce9adb33c97852c67f9d991d2ec81570706ca591 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 17 Apr 2026 00:59:35 +0900 Subject: [PATCH 32/34] =?UTF-8?q?refactor:=20Nickname=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=98=B8=EC=B6=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Nickname.Companion.create(nickname)` 형태의 명시적 `Companion` 참조를 `Nickname.create(nickname)`으로 변경하여 코드를 간결하게 수정하였습니다. --- .../prezel/core/domain/usecase/user/ValidateNicknameUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 204cea71..c99943aa 100644 --- 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 @@ -17,7 +17,7 @@ class ValidateNicknameUseCase @Inject constructor( private val userRepository: UserRepository, ) { suspend operator fun invoke(nickname: String): Result = - when (val creationResult = Nickname.Companion.create(nickname)) { + when (val creationResult = Nickname.create(nickname)) { is Nickname.CreationResult.Success -> { userRepository.checkNicknameDuplication(creationResult.nickname).fold( onSuccess = { isDuplicated -> From 46bb5a7ba431692fdfc006ddea3637362a063a61 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 17 Apr 2026 01:14:09 +0900 Subject: [PATCH 33/34] =?UTF-8?q?style:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EB=9E=80=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=ED=99=80=EB=8D=94=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `feature_profile_impl_nickname_text_field_placeholder` 리소스의 내용을 "닉네임을 입력해 주세요"에서 "최대 10자까지 입력이 가능해요"로 변경하였습니다. * 관련 리소스 영역의 주석을 `Helper Message`에서 `Nickname TextField`로 수정하였습니다. --- Prezel/feature/profile/impl/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Prezel/feature/profile/impl/src/main/res/values/strings.xml b/Prezel/feature/profile/impl/src/main/res/values/strings.xml index 4dee37eb..a4ef587f 100644 --- a/Prezel/feature/profile/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -7,8 +7,8 @@ 뒤로가기 프로필 - - 닉네임을 입력해 주세요 + + 최대 10자까지 입력이 가능해요 사용 가능한 닉네임이에요. 2자 이상 입력해 주세요. 이미 사용하고 있는 닉네임이에요. From 52041d965b5f5323cac273991d5ca2d16cbb1c67 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 21 Apr 2026 02:12:03 +0900 Subject: [PATCH 34/34] =?UTF-8?q?refactor:=20ProfileUiIntent=20=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProfileUiIntent`의 명칭을 행위 중심의 직관적인 이름으로 변경하고, 이를 `ProfileViewModel` 및 `ProfileScreen`에 반영하였습니다. * **refactor: ProfileUiIntent 파라미터 및 클래스 명칭 변경** * `OnNicknameChanged` -> `UpdateNickname` * `OnProfileImageChanged` -> `UpdateProfileImage` * `OnClickSubmit` -> `SubmitProfile` * **refactor: ViewModel 및 UI 레이어 내 변경 사항 반영** * `ProfileViewModel`의 `onIntent` 핸들러 내 변경된 Intent 명칭 적용 * `ProfileScreen`에서 `viewModel.onIntent` 호출 시 변경된 Intent 클래스 사용 --- .../com/team/prezel/feature/profile/impl/ProfileScreen.kt | 8 ++++---- .../team/prezel/feature/profile/impl/ProfileViewModel.kt | 6 +++--- .../feature/profile/impl/contract/ProfileUiIntent.kt | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) 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 d083275f..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 @@ -48,7 +48,7 @@ internal fun ProfileScreen( contract = ActivityResultContracts.PickVisualMedia(), ) { uri -> if (uri == null) return@rememberLauncherForActivityResult - viewModel.onIntent(ProfileUiIntent.OnProfileImageChanged(profileUrl = uri.toString())) + viewModel.onIntent(ProfileUiIntent.UpdateProfileImage(profileUrl = uri.toString())) } LaunchedEffect(Unit) { @@ -72,7 +72,7 @@ internal fun ProfileScreen( ProfileScreen( uiState = uiState, isNewProfile = isNewProfile, - onNicknameChanged = { nickname -> viewModel.onIntent(ProfileUiIntent.OnNicknameChanged(nickname)) }, + onNicknameChanged = { nickname -> viewModel.onIntent(ProfileUiIntent.UpdateNickname(nickname)) }, onClickProfileImage = { if (uiState.shouldLaunchPhotoPicker) { photoPickerLauncher.launch( @@ -81,9 +81,9 @@ internal fun ProfileScreen( return@ProfileScreen } - viewModel.onIntent(ProfileUiIntent.OnProfileImageChanged(profileUrl = "")) + viewModel.onIntent(ProfileUiIntent.UpdateProfileImage(profileUrl = "")) }, - onClickSubmit = { viewModel.onIntent(ProfileUiIntent.OnClickSubmit) }, + onClickSubmit = { viewModel.onIntent(ProfileUiIntent.SubmitProfile) }, onBack = onBack, modifier = modifier, ) 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 08eff32f..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 @@ -44,9 +44,9 @@ internal class ProfileViewModel @Inject constructor( override fun onIntent(intent: ProfileUiIntent) { when (intent) { ProfileUiIntent.FetchData -> fetchUserInfo() - is ProfileUiIntent.OnNicknameChanged -> handleNicknameChanged(intent.nickname) - is ProfileUiIntent.OnProfileImageChanged -> handleProfileImageChanged(intent.profileUrl) - ProfileUiIntent.OnClickSubmit -> submitProfile() + is ProfileUiIntent.UpdateNickname -> handleNicknameChanged(intent.nickname) + is ProfileUiIntent.UpdateProfileImage -> handleProfileImageChanged(intent.profileUrl) + ProfileUiIntent.SubmitProfile -> submitProfile() } } 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 index 9df9fe3d..3831ee18 100644 --- 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 @@ -5,13 +5,13 @@ import com.team.prezel.core.ui.UiIntent internal sealed interface ProfileUiIntent : UiIntent { data object FetchData : ProfileUiIntent - data class OnNicknameChanged( + data class UpdateNickname( val nickname: String, ) : ProfileUiIntent - data class OnProfileImageChanged( + data class UpdateProfileImage( val profileUrl: String, ) : ProfileUiIntent - data object OnClickSubmit : ProfileUiIntent + data object SubmitProfile : ProfileUiIntent }