From 522614c85e6b037d508f5e53d2c461ebb991fb84 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Sun, 31 May 2026 14:13:28 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EB=B1=83=EC=A7=80=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(SSE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 뱃지 기능 모듈(`feature:badge`) 추가 및 연동** * 뱃지 목록 및 상세 조회를 위한 `feature:badge:api`와 `feature:badge:impl` 모듈을 신규 생성했습니다. * `BadgeScreen`, `BadgeViewModel` 및 MVI 기반의 `BadgeUiState`, `BadgeUiIntent`, `BadgeUiEffect`를 구현했습니다. * `BadgeNavKey`를 통한 네비게이션 연동 및 `MyScreen`에서 뱃지 화면으로의 이동 로직을 추가했습니다. * **feat: 서버 데이터 기반 뱃지 도메인 모델 및 API 연동** * 기존의 하드코딩된 `BadgeType` Enum을 제거하고 서버 데이터를 반영하는 `Badge`, `BadgeDetail` 모델을 추가했습니다. * `BadgeService`, `BadgeRemoteDataSource`, `BadgeRepository`를 구현하여 서버로부터 뱃지 정보를 조회하도록 개선했습니다. * `FetchBadgesUseCase`, `FetchBadgeDetailUseCase` 등 뱃지 관련 유즈케이스를 정의했습니다. * **feat: SSE(Server-Sent Events)를 활용한 뱃지 획득 실시간 알림** * `api/stream/badges` 엔드포인트를 통해 실시간 뱃지 이벤트를 수신하는 SSE 연동 로직을 `BadgeRemoteDataSource`에 구현했습니다. * `MainActivity` 및 `PrezelApp`에서 뱃지 이벤트 스트림을 구독하고, 새 뱃지 획득 시 스낵바를 통해 사용자에게 알림을 표시하는 기능을 추가했습니다. * **refactor: `PrezelBadge` 컴포넌트 및 UI 로직 개선** * `PrezelBadge`가 로컬 리소스 대신 이미지 URL(`PrezelAsyncImage`)을 사용하도록 변경했습니다. * `MyScreen` 및 `BadgeSection`에서 정적 뱃지 데이터를 서버에서 받아온 데이터 기반으로 렌더링하도록 수정했습니다. * 불필요해진 로컬 뱃지 드로어블 리소스(`badge_start.xml`) 및 관련 문자열 리소스를 제거했습니다. * **build: 프로젝트 의존성 및 설정 업데이트** * 새 모듈 추가에 따라 `settings.gradle.kts` 및 각 모듈의 `build.gradle.kts` 의존성 설정을 업데이트했습니다. --- Prezel/app/build.gradle.kts | 3 + .../main/java/com/team/prezel/MainActivity.kt | 5 + .../main/java/com/team/prezel/ui/PrezelApp.kt | 43 ++++++ Prezel/app/src/main/res/values/strings.xml | 3 + .../prezel/core/data/di/RepositoryModule.kt | 6 + .../data/repository/BadgeRepositoryImpl.kt | 64 +++++++++ .../repository/badge/BadgeRepository.kt | 14 ++ .../badge/ConnectBadgeEventStreamUseCase.kt | 12 ++ .../usecase/badge/FetchBadgeDetailUseCase.kt | 11 ++ .../usecase/badge/FetchBadgesUseCase.kt | 11 ++ .../usecase/user/FetchUserBadgesUseCase.kt | 19 +-- .../com/team/prezel/core/model/badge/Badge.kt | 31 ++-- .../datasource/BadgeRemoteDataSource.kt | 14 ++ .../datasource/BadgeRemoteDataSourceImpl.kt | 132 ++++++++++++++++++ .../core/network/di/DataSourceModule.kt | 6 + .../prezel/core/network/di/NetworkModule.kt | 6 + .../network/model/badge/BadgeEventResponse.kt | 15 ++ .../model/badge/GetBadgeDetailResponse.kt | 22 +++ .../network/model/badge/GetBadgeResponse.kt | 18 +++ .../core/network/service/BadgeService.kt | 17 +++ .../prezel/core/ui/component/PrezelBadge.kt | 80 ++++------- .../ui/src/main/res/drawable/badge_start.xml | 19 --- .../core/ui/src/main/res/values/strings.xml | 8 -- Prezel/feature/badge/api/build.gradle.kts | 7 + .../prezel/feature/badge/api/BadgeNavKey.kt | 7 + Prezel/feature/badge/impl/build.gradle.kts | 16 +++ .../prezel/feature/badge/impl/BadgeScreen.kt | 116 +++++++++++++++ .../feature/badge/impl/BadgeViewModel.kt | 64 +++++++++ .../badge/impl/contract/BadgeUiEffect.kt | 10 ++ .../badge/impl/contract/BadgeUiIntent.kt | 9 ++ .../badge/impl/contract/BadgeUiState.kt | 16 +++ .../badge/impl/model/BadgeUiMessage.kt | 5 + .../feature/badge/impl/model/BadgeUiModel.kt | 13 ++ .../impl/navigation/BadgeEntryBuilder.kt | 33 +++++ Prezel/feature/my/impl/build.gradle.kts | 1 + .../team/prezel/feature/my/impl/MyScreen.kt | 17 +-- .../prezel/feature/my/impl/MyViewModel.kt | 15 +- .../feature/my/impl/component/BadgeSection.kt | 25 ++-- .../feature/my/impl/model/BadgeUiModel.kt | 12 +- .../my/impl/navigation/MyEntryBuilder.kt | 3 +- Prezel/settings.gradle.kts | 2 + 41 files changed, 782 insertions(+), 148 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/ConnectBadgeEventStreamUseCase.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgeDetailUseCase.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt delete mode 100644 Prezel/core/ui/src/main/res/drawable/badge_start.xml create mode 100644 Prezel/feature/badge/api/build.gradle.kts create mode 100644 Prezel/feature/badge/api/src/main/java/com/team/prezel/feature/badge/api/BadgeNavKey.kt create mode 100644 Prezel/feature/badge/impl/build.gradle.kts create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiEffect.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/navigation/BadgeEntryBuilder.kt diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 7146cf96..69974fc3 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.coreData) implementation(projects.coreDesignsystem) implementation(projects.coreDomain) + implementation(projects.coreModel) implementation(projects.coreNavigation) implementation(projects.coreUi) implementation(projects.coreCommon) @@ -48,6 +49,8 @@ dependencies { implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) implementation(projects.featureMyImpl) + implementation(projects.featureBadgeApi) + implementation(projects.featureBadgeImpl) implementation(projects.featureTermsImpl) implementation(projects.featurePracticeImpl) diff --git a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt index d3903118..a57c99ae 100644 --- a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt +++ b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.navigation3.runtime.NavKey import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.data.NetworkMonitor import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.domain.usecase.badge.ConnectBadgeEventStreamUseCase import com.team.prezel.ui.PrezelApp import com.team.prezel.ui.rememberPrezelAppState import dagger.hilt.android.AndroidEntryPoint @@ -23,6 +24,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var globalEventBus: GlobalEventBus + @Inject + lateinit var connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase + @Inject lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit> @@ -37,6 +41,7 @@ class MainActivity : ComponentActivity() { PrezelApp( appState = appState, globalEventBus = globalEventBus, + connectBadgeEventStreamUseCase = connectBadgeEventStreamUseCase, entryBuilders = entryBuilders.toImmutableSet(), ) } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index a87e27c8..01e95306 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -16,16 +16,20 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay +import com.team.prezel.R import com.team.prezel.core.common.event.GlobalEvent import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold import com.team.prezel.core.designsystem.component.PrezelNavigationScope +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.domain.usecase.badge.ConnectBadgeEventStreamUseCase import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope @@ -34,6 +38,7 @@ import com.team.prezel.core.ui.state.LocalAppDimmerState import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.core.ui.state.rememberAppDimmerState import com.team.prezel.core.ui.util.noRippleClickable +import com.team.prezel.feature.badge.api.BadgeNavKey import com.team.prezel.feature.splash.api.SplashNavKey import com.team.prezel.navigation.MAIN_NAV_ITEMS import kotlinx.collections.immutable.ImmutableSet @@ -42,6 +47,7 @@ import kotlinx.collections.immutable.ImmutableSet fun PrezelApp( appState: PrezelAppState, globalEventBus: GlobalEventBus, + connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase, entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } @@ -58,6 +64,7 @@ fun PrezelApp( PrezelAppContent( appState = appState, globalEventBus = globalEventBus, + connectBadgeEventStreamUseCase = connectBadgeEventStreamUseCase, entryBuilders = entryBuilders, ) } @@ -67,6 +74,7 @@ fun PrezelApp( private fun PrezelAppContent( appState: PrezelAppState, globalEventBus: GlobalEventBus, + connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase, entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = LocalNavigator.current @@ -77,6 +85,11 @@ private fun PrezelAppContent( navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, ) + ObserveBadgeEvents( + connectBadgeEventStreamUseCase = connectBadgeEventStreamUseCase, + navigateToBadge = { navigator.navigate(BadgeNavKey) }, + ) + Box(modifier = Modifier.fillMaxSize()) { SharedTransitionLayout { ProvideSharedTransitionScope(this@SharedTransitionLayout) { @@ -153,6 +166,36 @@ private fun ObserveGlobalEvents( } } +@Composable +private fun ObserveBadgeEvents( + connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase, + navigateToBadge: () -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current + val context = LocalContext.current + + LaunchedEffect(connectBadgeEventStreamUseCase) { + connectBadgeEventStreamUseCase().collect { event -> + val message = + event.message + ?.takeIf(String::isNotBlank) + ?: event.badgeName + ?.takeIf(String::isNotBlank) + ?.let { badgeName -> + context.getString(R.string.app_badge_event_message_with_name, badgeName) + } + ?: context.getString(R.string.app_badge_event_message) + + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar( + message = message, + actionLabel = context.getString(R.string.app_badge_event_action), + onAction = navigateToBadge, + ) + } + } +} + @Composable private fun PrezelNavigationScope.AppNavigationItems( appState: PrezelAppState, diff --git a/Prezel/app/src/main/res/values/strings.xml b/Prezel/app/src/main/res/values/strings.xml index dff017bb..2a6184e5 100644 --- a/Prezel/app/src/main/res/values/strings.xml +++ b/Prezel/app/src/main/res/values/strings.xml @@ -6,4 +6,7 @@ 프로필 한 번 더 누르면 앱을 종료합니다. + 새 배지를 획득했어요. + %1$s 배지를 획득했어요. + 보기 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 index 40c437a8..da943537 100644 --- 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 @@ -1,11 +1,13 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.repository.AuthRepositoryImpl +import com.team.prezel.core.data.repository.BadgeRepositoryImpl import com.team.prezel.core.data.repository.PracticeRepositoryImpl import com.team.prezel.core.data.repository.PresentationRepositoryImpl import com.team.prezel.core.data.repository.TermsRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.repository.badge.BadgeRepository import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.domain.repository.presentation.PresentationRepository import com.team.prezel.core.domain.repository.profile.UserRepository @@ -23,6 +25,10 @@ internal abstract class RepositoryModule { @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + @Binds + @Singleton + abstract fun bindBadgeRepository(impl: BadgeRepositoryImpl): BadgeRepository + @Binds @Singleton abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt new file mode 100644 index 00000000..48ad1076 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt @@ -0,0 +1,64 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.data.error.mapDomainFailure +import com.team.prezel.core.domain.repository.badge.BadgeRepository +import com.team.prezel.core.model.badge.Badge +import com.team.prezel.core.model.badge.BadgeDetail +import com.team.prezel.core.model.badge.BadgeEvent +import com.team.prezel.core.network.datasource.BadgeRemoteDataSource +import com.team.prezel.core.network.model.badge.BadgeEventResponse +import com.team.prezel.core.network.model.badge.GetBadgeDetailResponse +import com.team.prezel.core.network.model.badge.GetBadgeResponse +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +internal class BadgeRepositoryImpl @Inject constructor( + private val badgeRemoteDataSource: BadgeRemoteDataSource, +) : BadgeRepository { + override suspend fun getBadges(): Result> = + runCatching { + badgeRemoteDataSource.getBadges() + }.mapCatching { response -> + response.map(GetBadgeResponse::toDomain) + }.mapDomainFailure() + + override suspend fun getBadgeDetail(badgeCode: String): Result = + runCatching { + badgeRemoteDataSource.getBadgeDetail(badgeCode = badgeCode) + }.mapCatching(GetBadgeDetailResponse::toDomain) + .mapDomainFailure() + + override fun connectBadgeEventStream(): Flow = + badgeRemoteDataSource + .connectBadgeEventStream() + .map(BadgeEventResponse::toDomain) +} + +private fun GetBadgeResponse.toDomain(): Badge = + Badge( + badgeCode = badgeCode, + badgeName = badgeName, + isUnlocked = isUnlocked, + imageUrl = imageUrl, + unlockedAt = unlockedAt.takeIf(String::isNotBlank), + ) + +private fun GetBadgeDetailResponse.toDomain(): BadgeDetail = + BadgeDetail( + badgeCode = badgeCode, + badgeName = badgeName, + conditionText = conditionText, + detailDescription = detailDescription, + imageUrl = imageUrl, + isUnlocked = isUnlocked, + unlockedAt = unlockedAt.takeIf(String::isNotBlank), + ) + +private fun BadgeEventResponse.toDomain(): BadgeEvent = + BadgeEvent( + badgeCode = badgeCode, + badgeName = badgeName, + message = message, + rawData = rawData, + ) diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt new file mode 100644 index 00000000..02d7d34f --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.domain.repository.badge + +import com.team.prezel.core.model.badge.Badge +import com.team.prezel.core.model.badge.BadgeDetail +import com.team.prezel.core.model.badge.BadgeEvent +import kotlinx.coroutines.flow.Flow + +interface BadgeRepository { + suspend fun getBadges(): Result> + + suspend fun getBadgeDetail(badgeCode: String): Result + + fun connectBadgeEventStream(): Flow +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/ConnectBadgeEventStreamUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/ConnectBadgeEventStreamUseCase.kt new file mode 100644 index 00000000..5e059661 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/ConnectBadgeEventStreamUseCase.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.domain.usecase.badge + +import com.team.prezel.core.domain.repository.badge.BadgeRepository +import com.team.prezel.core.model.badge.BadgeEvent +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ConnectBadgeEventStreamUseCase @Inject constructor( + private val badgeRepository: BadgeRepository, +) { + operator fun invoke(): Flow = badgeRepository.connectBadgeEventStream() +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgeDetailUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgeDetailUseCase.kt new file mode 100644 index 00000000..ea71c781 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgeDetailUseCase.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.badge + +import com.team.prezel.core.domain.repository.badge.BadgeRepository +import com.team.prezel.core.model.badge.BadgeDetail +import javax.inject.Inject + +class FetchBadgeDetailUseCase @Inject constructor( + private val badgeRepository: BadgeRepository, +) { + suspend operator fun invoke(badgeCode: String): Result = badgeRepository.getBadgeDetail(badgeCode = badgeCode) +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt new file mode 100644 index 00000000..76f62bd6 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.badge + +import com.team.prezel.core.domain.repository.badge.BadgeRepository +import com.team.prezel.core.model.badge.Badge +import javax.inject.Inject + +class FetchBadgesUseCase @Inject constructor( + private val badgeRepository: BadgeRepository, +) { + suspend operator fun invoke(): Result> = badgeRepository.getBadges() +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserBadgesUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserBadgesUseCase.kt index 654e11da..89aae0e4 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserBadgesUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/FetchUserBadgesUseCase.kt @@ -1,20 +1,11 @@ package com.team.prezel.core.domain.usecase.user +import com.team.prezel.core.domain.usecase.badge.FetchBadgesUseCase import com.team.prezel.core.model.badge.Badge -import com.team.prezel.core.model.badge.BadgeType import javax.inject.Inject -import kotlin.random.Random -class FetchUserBadgesUseCase @Inject constructor() { - suspend operator fun invoke(): Result> = - runCatching { - listOf( - BadgeType.FIRST_PRESENTATION, - BadgeType.SECOND_ANALYSIS, - BadgeType.FIRST_PRACTICE, - BadgeType.RETROSPECT_COMPLETED, - BadgeType.PERFECT_SCORE, - BadgeType.TEN_ANALYSIS, - ).map { type -> Badge(type = type, isAchieved = Random.nextBoolean()) } - } +class FetchUserBadgesUseCase @Inject constructor( + private val fetchBadgesUseCase: FetchBadgesUseCase, +) { + suspend operator fun invoke(): Result> = fetchBadgesUseCase() } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt index 4fd5c30d..c813868e 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt @@ -1,15 +1,26 @@ package com.team.prezel.core.model.badge data class Badge( - val type: BadgeType, - val isAchieved: Boolean, + val badgeCode: String, + val badgeName: String, + val isUnlocked: Boolean, + val imageUrl: String, + val unlockedAt: String? = null, ) -enum class BadgeType { - FIRST_PRESENTATION, - SECOND_ANALYSIS, - FIRST_PRACTICE, - RETROSPECT_COMPLETED, - PERFECT_SCORE, - TEN_ANALYSIS, -} +data class BadgeDetail( + val badgeCode: String, + val badgeName: String, + val conditionText: String, + val detailDescription: String, + val imageUrl: String, + val isUnlocked: Boolean, + val unlockedAt: String? = null, +) + +data class BadgeEvent( + val badgeCode: String? = null, + val badgeName: String? = null, + val message: String? = null, + val rawData: String? = null, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt new file mode 100644 index 00000000..6326040b --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.badge.BadgeEventResponse +import com.team.prezel.core.network.model.badge.GetBadgeDetailResponse +import com.team.prezel.core.network.model.badge.GetBadgeResponse +import kotlinx.coroutines.flow.Flow + +interface BadgeRemoteDataSource { + suspend fun getBadges(): List + + suspend fun getBadgeDetail(badgeCode: String): GetBadgeDetailResponse + + fun connectBadgeEventStream(): Flow +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt new file mode 100644 index 00000000..c224b3a9 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt @@ -0,0 +1,132 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.badge.BadgeEventResponse +import com.team.prezel.core.network.model.badge.GetBadgeDetailResponse +import com.team.prezel.core.network.model.badge.GetBadgeResponse +import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.service.BadgeService +import io.ktor.client.HttpClient +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.utils.io.readUTF8Line +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import timber.log.Timber +import javax.inject.Inject + +internal class BadgeRemoteDataSourceImpl @Inject constructor( + private val badgeService: BadgeService, + private val httpClient: HttpClient, +) : BadgeRemoteDataSource { + private val sseJson: Json = + Json { + ignoreUnknownKeys = true + } + + override suspend fun getBadges(): List = badgeService.getBadges().requireData() + + override suspend fun getBadgeDetail(badgeCode: String): GetBadgeDetailResponse = badgeService.getBadgeDetail(badgeCode = badgeCode).requireData() + + override fun connectBadgeEventStream(): Flow = + callbackFlow { + val response = httpClient.get("api/stream/badges") { + accept(ContentType.Text.EventStream) + } + + val readerJob = launch { + try { + response.readBadgeEvents { event -> + trySend(event) + } + close() + } catch (throwable: CancellationException) { + throw throwable + } catch (throwable: Throwable) { + close(throwable) + } + } + + awaitClose { + readerJob.cancel() + } + } + + private suspend fun HttpResponse.readBadgeEvents(emit: suspend (BadgeEventResponse) -> Unit) { + val channel = bodyAsChannel() + var eventName: String? = null + val dataBuffer = StringBuilder() + + while (!channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: break + Timber.tag("TEST").i("Event: $line") + + when { + line.isBlank() -> { + dispatchBadgeEvent( + eventName = eventName, + data = dataBuffer.toString(), + emit = emit, + ) + eventName = null + dataBuffer.clear() + } + + line.startsWith("event:") -> { + eventName = line.substringAfter("event:").trim().takeIf(String::isNotBlank) + } + + line.startsWith("data:") -> { + if (dataBuffer.isNotEmpty()) { + dataBuffer.append('\n') + } + dataBuffer.append(line.substringAfter("data:").trimStart()) + } + } + } + + dispatchBadgeEvent( + eventName = eventName, + data = dataBuffer.toString(), + emit = emit, + ) + } + + private suspend fun dispatchBadgeEvent( + eventName: String?, + data: String, + emit: suspend (BadgeEventResponse) -> Unit, + ) { + val rawData = data.trim() + if (rawData.isBlank()) return + + emit(rawData.toBadgeEventResponse(eventName = eventName)) + } + + private fun String.toBadgeEventResponse(eventName: String?): BadgeEventResponse { + val payload = runCatching { + sseJson.decodeFromString(this) + }.getOrNull() + + return BadgeEventResponse( + badgeCode = payload?.badgeCode, + badgeName = payload?.badgeName, + message = payload?.message ?: eventName, + rawData = this, + ) + } + + @Serializable + private data class BadgeEventPayload( + val badgeCode: String? = null, + val badgeName: String? = null, + val message: String? = null, + ) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt index 2db25b0e..d8c32ea1 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt @@ -2,6 +2,8 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSourceImpl +import com.team.prezel.core.network.datasource.BadgeRemoteDataSource +import com.team.prezel.core.network.datasource.BadgeRemoteDataSourceImpl import com.team.prezel.core.network.datasource.PracticeRemoteDataSource import com.team.prezel.core.network.datasource.PracticeRemoteDataSourceImpl import com.team.prezel.core.network.datasource.PresentationRemoteDataSource @@ -23,6 +25,10 @@ internal abstract class DataSourceModule { @Singleton abstract fun bindAuthRemoteDataSource(impl: AuthRemoteDataSourceImpl): AuthRemoteDataSource + @Binds + @Singleton + abstract fun bindBadgeRemoteDataSource(impl: BadgeRemoteDataSourceImpl): BadgeRemoteDataSource + @Binds @Singleton abstract fun bindUserRemoteDataSource(impl: UserRemoteDataSourceImpl): UserRemoteDataSource diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index d399f4e2..b3a15eed 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -2,11 +2,13 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.client.HttpClientFactory import com.team.prezel.core.network.service.AuthService +import com.team.prezel.core.network.service.BadgeService import com.team.prezel.core.network.service.PracticeService import com.team.prezel.core.network.service.PresentationService import com.team.prezel.core.network.service.TermsService import com.team.prezel.core.network.service.UserService import com.team.prezel.core.network.service.createAuthService +import com.team.prezel.core.network.service.createBadgeService import com.team.prezel.core.network.service.createPracticeService import com.team.prezel.core.network.service.createPresentationService import com.team.prezel.core.network.service.createTermsService @@ -38,6 +40,10 @@ object NetworkModule { @Singleton internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() + @Provides + @Singleton + internal fun provideBadgeService(ktorfit: Ktorfit): BadgeService = ktorfit.createBadgeService() + @Provides @Singleton internal fun provideUserService(ktorfit: Ktorfit): UserService = ktorfit.createUserService() diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt new file mode 100644 index 00000000..db2b7f67 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.network.model.badge + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BadgeEventResponse( + @SerialName("badgeCode") + val badgeCode: String? = null, + @SerialName("badgeName") + val badgeName: String? = null, + @SerialName("message") + val message: String? = null, + val rawData: String? = null, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt new file mode 100644 index 00000000..199b040b --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt @@ -0,0 +1,22 @@ +package com.team.prezel.core.network.model.badge + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetBadgeDetailResponse( + @SerialName("badgeCode") + val badgeCode: String, + @SerialName("badgeName") + val badgeName: String, + @SerialName("conditionText") + val conditionText: String, + @SerialName("detailDescription") + val detailDescription: String, + @SerialName("imageUrl") + val imageUrl: String, + @SerialName("isUnlocked") + val isUnlocked: Boolean, + @SerialName("unlockedAt") + val unlockedAt: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt new file mode 100644 index 00000000..a8685904 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt @@ -0,0 +1,18 @@ +package com.team.prezel.core.network.model.badge + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetBadgeResponse( + @SerialName("badgeCode") + val badgeCode: String, + @SerialName("badgeName") + val badgeName: String, + @SerialName("imageUrl") + val imageUrl: String, + @SerialName("isUnlocked") + val isUnlocked: Boolean, + @SerialName("unlockedAt") + val unlockedAt: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt new file mode 100644 index 00000000..4d3f5ef7 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.network.service + +import com.team.prezel.core.network.model.BaseResponse +import com.team.prezel.core.network.model.badge.GetBadgeDetailResponse +import com.team.prezel.core.network.model.badge.GetBadgeResponse +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.Path + +interface BadgeService { + @GET("badges") + suspend fun getBadges(): BaseResponse> + + @GET("badges/{badgeCode}") + suspend fun getBadgeDetail( + @Path("badgeCode") badgeCode: String, + ): BaseResponse +} diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelBadge.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelBadge.kt index 7f46dc55..9de90896 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelBadge.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelBadge.kt @@ -1,17 +1,16 @@ package com.team.prezel.core.ui.component -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,18 +19,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.PrezelAsyncImage 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.badge.BadgeType -import com.team.prezel.core.ui.R @Composable fun PrezelBadge( title: String, - @DrawableRes badgeResId: Int, + url: String, isAchieved: Boolean, modifier: Modifier = Modifier, ) { @@ -39,7 +35,7 @@ fun PrezelBadge( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { - BadgeImage(resId = badgeResId, isAchieved = isAchieved) + BadgeImage(imageUrl = url, isAchieved = isAchieved) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) @@ -51,32 +47,9 @@ fun PrezelBadge( } } -@Composable -fun BadgeType.title(): String = - when (this) { - BadgeType.FIRST_PRESENTATION -> R.string.core_ui_impl_badge_first_presentation - BadgeType.SECOND_ANALYSIS -> R.string.core_ui_impl_badge_second_analysis - BadgeType.FIRST_PRACTICE -> R.string.core_ui_impl_badge_first_practice - BadgeType.RETROSPECT_COMPLETED -> R.string.core_ui_impl_badge_retrospect_completed - BadgeType.PERFECT_SCORE -> R.string.core_ui_impl_badge_perfect_score - BadgeType.TEN_ANALYSIS -> R.string.core_ui_impl_badge_ten_analysis - }.let { resId -> stringResource(resId) } - -// todo: 뱃지 디자인 업데이트 완료 후 반영할 예정 -@Composable -fun BadgeType.drawableResId(): Int = - when (this) { - BadgeType.FIRST_PRESENTATION -> R.drawable.badge_start - BadgeType.SECOND_ANALYSIS -> R.drawable.badge_start - BadgeType.FIRST_PRACTICE -> R.drawable.badge_start - BadgeType.RETROSPECT_COMPLETED -> R.drawable.badge_start - BadgeType.PERFECT_SCORE -> R.drawable.badge_start - BadgeType.TEN_ANALYSIS -> R.drawable.badge_start - } - @Composable private fun BadgeImage( - @DrawableRes resId: Int, + imageUrl: String, isAchieved: Boolean, modifier: Modifier = Modifier, ) { @@ -86,11 +59,11 @@ private fun BadgeImage( .aspectRatio(1f) .clip(shape = PrezelTheme.shapes.V16), ) { - Image( - painter = painterResource(resId), - contentDescription = null, - contentScale = ContentScale.Crop, + PrezelAsyncImage( + url = imageUrl, + contentDescription = "", modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, ) if (!isAchieved) { @@ -113,31 +86,26 @@ private fun BadgeImage( @BasicPreview @Composable -private fun PrezelBadgeAchievedPreview() { - val badgeType = BadgeType.FIRST_PRESENTATION +private fun PrezelBadgePreview() { PrezelTheme { - Box(modifier = Modifier.padding(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgRegular) + .padding(PrezelTheme.spacing.V16), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { PrezelBadge( - badgeResId = badgeType.drawableResId(), - title = badgeType.title(), + title = "Achieved", + url = "https://picsum.photos/200", isAchieved = true, - modifier = Modifier.width(100.dp), + modifier = Modifier.weight(1f), ) - } - } -} - -@BasicPreview -@Composable -private fun PrezelBadgeNotAchievedPreview() { - val badgeType = BadgeType.FIRST_PRESENTATION - PrezelTheme { - Box(modifier = Modifier.padding(8.dp)) { PrezelBadge( - badgeResId = badgeType.drawableResId(), - title = badgeType.title(), + title = "Locked", + url = "https://picsum.photos/200", isAchieved = false, - modifier = Modifier.width(100.dp), + modifier = Modifier.weight(1f), ) } } diff --git a/Prezel/core/ui/src/main/res/drawable/badge_start.xml b/Prezel/core/ui/src/main/res/drawable/badge_start.xml deleted file mode 100644 index c2fd6dac..00000000 --- a/Prezel/core/ui/src/main/res/drawable/badge_start.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index 7618e19f..762bddc8 100644 --- a/Prezel/core/ui/src/main/res/values/strings.xml +++ b/Prezel/core/ui/src/main/res/values/strings.xml @@ -1,13 +1,5 @@ - - 시작이 반 - 감 잡는 중 - 워밍업 - 끝까지 해냄 - 컨디션 최고 - 감 잡았다 - 맞춤법 주술호응 diff --git a/Prezel/feature/badge/api/build.gradle.kts b/Prezel/feature/badge/api/build.gradle.kts new file mode 100644 index 00000000..214dccd5 --- /dev/null +++ b/Prezel/feature/badge/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.android.feature.api) +} + +android { + namespace = "com.team.prezel.feature.badge.api" +} diff --git a/Prezel/feature/badge/api/src/main/java/com/team/prezel/feature/badge/api/BadgeNavKey.kt b/Prezel/feature/badge/api/src/main/java/com/team/prezel/feature/badge/api/BadgeNavKey.kt new file mode 100644 index 00000000..e609467b --- /dev/null +++ b/Prezel/feature/badge/api/src/main/java/com/team/prezel/feature/badge/api/BadgeNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.badge.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object BadgeNavKey : NavKey diff --git a/Prezel/feature/badge/impl/build.gradle.kts b/Prezel/feature/badge/impl/build.gradle.kts new file mode 100644 index 00000000..870b2602 --- /dev/null +++ b/Prezel/feature/badge/impl/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.badge.impl" +} + +dependencies { + implementation(projects.coreModel) + implementation(projects.coreDomain) + + implementation(projects.featureBadgeApi) + + implementation(libs.kotlinx.collections.immutable) +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt new file mode 100644 index 00000000..113fbf12 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt @@ -0,0 +1,116 @@ +package com.team.prezel.feature.badge.impl + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +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.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.component.PrezelBadge +import com.team.prezel.feature.badge.impl.contract.BadgeUiState +import com.team.prezel.feature.badge.impl.model.BadgeUiModel +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BadgeScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: BadgeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + BadgeScreenContent( + uiState = uiState, + onBack = onBack, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BadgeScreenContent( + uiState: BadgeUiState, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + ) { + PrezelTopAppBar( + title = { Text(text = "나의 뱃지") }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ChevronLeft), + contentDescription = "뒤로가기", + ) + } + }, + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V16, horizontal = PrezelTheme.spacing.V20), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + overscrollEffect = null, + ) { + items(items = uiState.badges, key = { badge -> badge.badgeCode }) { badge -> + PrezelBadge( + title = badge.badgeName, + url = badge.imageUrl, + isAchieved = badge.isUnlocked, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BadgeScreenPreview() { + PrezelTheme { + BadgeScreenContent( + uiState = BadgeUiState( + badges = persistentListOf( + BadgeUiModel( + badgeCode = "1", + badgeName = "첫 발표", + conditionText = "첫 발표를 완료하세요", + detailDescription = "첫 발표를 완료하면 획득할 수 있습니다.", + imageUrl = "", + isUnlocked = true, + ), + BadgeUiModel( + badgeCode = "2", + badgeName = "분석 왕", + conditionText = "발표 분석을 10번 완료하세요", + detailDescription = "발표 분석을 10번 완료하면 획득할 수 있습니다.", + imageUrl = "", + isUnlocked = false, + ), + ), + selectedBadgeCode = "1", + ), + onBack = {}, + ) + } +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt new file mode 100644 index 00000000..38bea7a8 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt @@ -0,0 +1,64 @@ +package com.team.prezel.feature.badge.impl + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.badge.FetchBadgeDetailUseCase +import com.team.prezel.core.domain.usecase.badge.FetchBadgesUseCase +import com.team.prezel.core.model.badge.Badge +import com.team.prezel.core.model.badge.BadgeDetail +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.badge.impl.contract.BadgeUiEffect +import com.team.prezel.feature.badge.impl.contract.BadgeUiIntent +import com.team.prezel.feature.badge.impl.contract.BadgeUiState +import com.team.prezel.feature.badge.impl.model.BadgeUiMessage +import com.team.prezel.feature.badge.impl.model.BadgeUiModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class BadgeViewModel @Inject constructor( + private val fetchBadgesUseCase: FetchBadgesUseCase, + private val fetchBadgeDetailUseCase: FetchBadgeDetailUseCase, +) : BaseViewModel(BadgeUiState()) { + init { + fetchData() + } + + override fun onIntent(intent: BadgeUiIntent) { + when (intent) { + is BadgeUiIntent.ClickBadge -> {} + } + } + + private fun fetchData() { + viewModelScope.launch { + fetchBadgesUseCase() + .onSuccess { badges -> handleFetchDataSuccess(badges = badges) } + .onFailure { sendEffect(BadgeUiEffect.ShowMessage(BadgeUiMessage.FETCH_DATA_FAILED)) } + } + } + + private suspend fun handleFetchDataSuccess(badges: List) = + coroutineScope { + val badgeDetails = badges + .map { badge -> async { fetchBadgeDetailUseCase(badgeCode = badge.badgeCode) } } + .awaitAll() + .mapNotNull(Result::getOrNull) + .map { detail -> + with(detail) { + BadgeUiModel( + badgeCode = badgeCode, + badgeName = badgeName, + conditionText = conditionText, + detailDescription = detailDescription, + imageUrl = imageUrl, + isUnlocked = isUnlocked, + ) + } + }.toImmutableList() + + updateState { copy(badges = badgeDetails) } + } +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiEffect.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiEffect.kt new file mode 100644 index 00000000..1d368196 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiEffect.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.badge.impl.contract + +import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.badge.impl.model.BadgeUiMessage + +internal sealed interface BadgeUiEffect : UiEffect { + data class ShowMessage( + val message: BadgeUiMessage, + ) : BadgeUiEffect +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt new file mode 100644 index 00000000..5681adb5 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt @@ -0,0 +1,9 @@ +package com.team.prezel.feature.badge.impl.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface BadgeUiIntent : UiIntent { + data class ClickBadge( + val badgeCode: String, + ) : BadgeUiIntent +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt new file mode 100644 index 00000000..09809efb --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt @@ -0,0 +1,16 @@ +package com.team.prezel.feature.badge.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.badge.impl.model.BadgeUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class BadgeUiState( + val isLoading: Boolean = false, + val badges: ImmutableList = persistentListOf(), + val selectedBadgeCode: String? = null, +) : UiState { + val selectedBadge: BadgeUiModel = badges.first { badge -> badge.badgeCode == selectedBadgeCode } +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt new file mode 100644 index 00000000..bb0eb982 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.badge.impl.model + +internal enum class BadgeUiMessage { + FETCH_DATA_FAILED, +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt new file mode 100644 index 00000000..7df644fd --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt @@ -0,0 +1,13 @@ +package com.team.prezel.feature.badge.impl.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class BadgeUiModel( + val badgeCode: String, + val badgeName: String, + val conditionText: String, + val detailDescription: String, + val imageUrl: String, + val isUnlocked: Boolean, +) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/navigation/BadgeEntryBuilder.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/navigation/BadgeEntryBuilder.kt new file mode 100644 index 00000000..1a5e91d8 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/navigation/BadgeEntryBuilder.kt @@ -0,0 +1,33 @@ +package com.team.prezel.feature.badge.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.badge.api.BadgeNavKey +import com.team.prezel.feature.badge.impl.BadgeScreen +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +internal fun EntryProviderScope.featureBadgeEntryBuilder() { + entry { + val navigator = LocalNavigator.current + + BadgeScreen( + onBack = { navigator.goBack() }, + ) + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeatureBadgeModule { + @IntoSet + @Provides + fun provideFeatureBadgeEntryBuilder(): EntryProviderScope.() -> Unit = + { + featureBadgeEntryBuilder() + } +} diff --git a/Prezel/feature/my/impl/build.gradle.kts b/Prezel/feature/my/impl/build.gradle.kts index 3f8a2585..145e2baf 100644 --- a/Prezel/feature/my/impl/build.gradle.kts +++ b/Prezel/feature/my/impl/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(projects.coreDomain) implementation(projects.coreModel) + implementation(projects.featureBadgeApi) implementation(projects.featureMyApi) implementation(projects.featureSettingApi) implementation(projects.featureProfileApi) 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 index 596fb474..fd071d7f 100644 --- 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 @@ -16,7 +16,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.model.badge.BadgeType import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.my.impl.component.BadgeSection import com.team.prezel.feature.my.impl.component.MyTopAppBar @@ -106,15 +105,13 @@ private fun MyScreenPreview() { uiState = MyUiState( profileImageUrl = null, nickname = "닉네임", - badges = listOf( - BadgeType.FIRST_PRESENTATION, - BadgeType.SECOND_ANALYSIS, - BadgeType.FIRST_PRACTICE, - BadgeType.RETROSPECT_COMPLETED, - BadgeType.PERFECT_SCORE, - BadgeType.TEN_ANALYSIS, - ).mapIndexed { index, type -> - BadgeUiModel(type = type, isAchieved = index % 2 == 0) + badges = List(6) { index -> + BadgeUiModel( + code = index.toString(), + title = index.toString(), + imageUrl = "", + isAchieved = index % 2 == 0, + ) }.toImmutableList(), ), onClickEditProfile = {}, 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 index 89368ad5..e57b6aea 100644 --- 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 @@ -1,9 +1,8 @@ package com.team.prezel.feature.my.impl import androidx.lifecycle.viewModelScope -import com.team.prezel.core.domain.usecase.user.FetchUserBadgesUseCase +import com.team.prezel.core.domain.usecase.badge.FetchBadgesUseCase import com.team.prezel.core.domain.usecase.user.FetchUserInfoUseCase -import com.team.prezel.core.model.badge.Badge import com.team.prezel.core.model.profile.User import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.my.impl.contract.MyUiEffect @@ -11,7 +10,6 @@ import com.team.prezel.feature.my.impl.contract.MyUiIntent import com.team.prezel.feature.my.impl.contract.MyUiState import com.team.prezel.feature.my.impl.model.BadgeUiModel import com.team.prezel.feature.my.impl.model.MyUiMessage -import com.team.prezel.feature.my.impl.model.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -23,7 +21,7 @@ import javax.inject.Inject @HiltViewModel internal class MyViewModel @Inject constructor( private val fetchUserInfoUseCase: FetchUserInfoUseCase, - private val fetchUserBadgesUseCase: FetchUserBadgesUseCase, + private val fetchBadgesUseCase: FetchBadgesUseCase, ) : BaseViewModel(MyUiState()) { override fun onIntent(intent: MyUiIntent) { when (intent) { @@ -63,8 +61,13 @@ internal class MyViewModel @Inject constructor( ) private suspend fun fetchMyBadges(): ImmutableList? = - fetchUserBadgesUseCase().fold( - onSuccess = { badges -> badges.map(Badge::toUiModel).toImmutableList() }, + fetchBadgesUseCase().fold( + onSuccess = { badges -> + badges + .map { badge -> + BadgeUiModel(code = badge.badgeCode, title = badge.badgeName, imageUrl = badge.imageUrl, isAchieved = badge.isUnlocked) + }.toImmutableList() + }, onFailure = { throwable -> Timber.e(t = throwable) sendEffect(MyUiEffect.ShowMessage(MyUiMessage.FETCH_USER_BADGES_FAILED)) diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/BadgeSection.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/BadgeSection.kt index ffee8f44..36d23292 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/BadgeSection.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/BadgeSection.kt @@ -22,10 +22,7 @@ import com.team.prezel.core.designsystem.component.list.PrezelListSize 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.badge.BadgeType import com.team.prezel.core.ui.component.PrezelBadge -import com.team.prezel.core.ui.component.drawableResId -import com.team.prezel.core.ui.component.title import com.team.prezel.feature.my.impl.model.BadgeUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -90,10 +87,10 @@ private fun BadgeList( horizontalArrangement = Arrangement.spacedBy(itemSpacing), overscrollEffect = null, ) { - items(items = badges, key = { badge -> badge.type }) { badge -> + items(items = badges, key = { badge -> badge.code }) { badge -> PrezelBadge( - title = badge.type.title(), - badgeResId = badge.type.drawableResId(), + title = badge.title, + url = badge.imageUrl, isAchieved = badge.isAchieved, modifier = Modifier.width(itemSize), ) @@ -107,15 +104,13 @@ private fun BadgeList( private fun BadgeSectionPreview() { PrezelTheme { BadgeSection( - badges = listOf( - BadgeType.FIRST_PRESENTATION, - BadgeType.SECOND_ANALYSIS, - BadgeType.FIRST_PRACTICE, - BadgeType.RETROSPECT_COMPLETED, - BadgeType.PERFECT_SCORE, - BadgeType.TEN_ANALYSIS, - ).mapIndexed { index, type -> - BadgeUiModel(type = type, isAchieved = index % 2 == 0) + badges = List(6) { index -> + BadgeUiModel( + code = index.toString(), + title = index.toString(), + imageUrl = "", + isAchieved = index % 2 == 0, + ) }.toImmutableList(), onClickBadge = {}, ) diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/BadgeUiModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/BadgeUiModel.kt index e0d9b15c..9fbb41ab 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/BadgeUiModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/BadgeUiModel.kt @@ -1,17 +1,11 @@ package com.team.prezel.feature.my.impl.model import androidx.compose.runtime.Immutable -import com.team.prezel.core.model.badge.Badge -import com.team.prezel.core.model.badge.BadgeType @Immutable internal data class BadgeUiModel( - val type: BadgeType, + val code: String, + val title: String, + val imageUrl: String, val isAchieved: Boolean, ) - -internal fun Badge.toUiModel(): BadgeUiModel = - BadgeUiModel( - type = type, - isAchieved = isAchieved, - ) 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 index 69373811..653a68dc 100644 --- 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 @@ -3,6 +3,7 @@ package com.team.prezel.feature.my.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.badge.api.BadgeNavKey import com.team.prezel.feature.my.api.MyNavKey import com.team.prezel.feature.my.impl.MyScreen import com.team.prezel.feature.profile.api.ProfileNavKey @@ -20,7 +21,7 @@ internal fun EntryProviderScope.featureMyEntryBuilder() { MyScreen( navigateToEditProfile = { navigator.navigate(ProfileNavKey.Edit) }, navigateToSetting = { navigator.navigate(SettingNavKey) }, - navigateToBadge = { /* navigator.navigate(ProfileNavKey.Badge) */ }, + navigateToBadge = { navigator.navigate(BadgeNavKey) }, ) } } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 83ede2fb..8101e95d 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -56,6 +56,8 @@ includeAuto( ":feature:practice:impl", ":feature:analysis:api", ":feature:analysis:impl", + ":feature:badge:api", + ":feature:badge:impl", ":feature:history:api", ":feature:history:impl", ":feature:my:api", From 01db47cde948bab54c5ae735c432a2f10b0a8a6b Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 00:04:53 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=EB=B1=83=EC=A7=80=20SSE=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EA=B5=AC=ED=98=84=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=AA=A8=EB=8D=B8=20=EC=A0=95=ED=95=A9=EC=84=B1=20?= =?UTF-8?q?=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: Ktor SSE 플러그인 도입 및 스트림 로직 개선** * `HttpClientFactory`에 Ktor `SSE` 플러그인 설정을 추가했습니다. * `BadgeRemoteDataSourceImpl`에서 수동으로 응답 채널을 읽던 로직을 `serverSentEvents` 확장 함수를 사용하는 방식으로 리팩터링했습니다. * SSE 이벤트 파싱 로직을 `BadgeSseEventParser`로 분리하고, `badge_unlocked` 이벤트에 대한 처리를 구체화했습니다. * **feat: 뱃지 관련 데이터 모델 필드 추가 및 타입 수정** * `BadgeEvent` 및 `BadgeEventResponse` 모델에 `introduction`, `imageUrl` 필드를 추가했습니다. * `GetBadgeResponse`와 `GetBadgeDetailResponse`의 `unlockedAt` 타입을 `String?`로 변경하여 null 허용 상태를 반영했습니다. * `BadgeUiState`에서 `selectedBadge`를 조회할 때 `firstOrNull`을 사용하도록 하여 안정성을 높였습니다. * **refactor: 인증 상태에 따른 뱃지 이벤트 구독 제어** * `PrezelAppState`에 현재 경로가 인증이 필요한 페이지인지 확인하는 `isAuthenticated` 로직을 추가했습니다. * `ObserveBadgeEvents` 컴포저블에서 인증된 사용자만 뱃지 이벤트 스트림을 구독하도록 수정했습니다. * **fix: 의존성 주입 및 매핑 로직 수정** * `BadgeViewModel`에 누락되었던 `@HiltViewModel` 어노테이션을 추가했습니다. * `BadgeRepositoryImpl`에서 `unlockedAt` 매핑 시 불필요한 `isNotBlank` 체크 로직을 제거했습니다. --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 41 +++--- .../java/com/team/prezel/ui/PrezelAppState.kt | 9 ++ .../data/repository/BadgeRepositoryImpl.kt | 6 +- .../com/team/prezel/core/model/badge/Badge.kt | 2 + .../core/network/client/HttpClientFactory.kt | 10 ++ .../datasource/BadgeRemoteDataSourceImpl.kt | 129 +++++------------- .../network/datasource/BadgeSseEventParser.kt | 58 ++++++++ .../network/model/badge/BadgeEventResponse.kt | 4 + .../model/badge/GetBadgeDetailResponse.kt | 2 +- .../network/model/badge/GetBadgeResponse.kt | 2 +- .../feature/badge/impl/BadgeViewModel.kt | 2 + .../badge/impl/contract/BadgeUiState.kt | 2 +- 12 files changed, 148 insertions(+), 119 deletions(-) create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeSseEventParser.kt diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 01e95306..578969bf 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -86,6 +86,7 @@ private fun PrezelAppContent( ) ObserveBadgeEvents( + isAuthenticated = appState.isAuthenticated, connectBadgeEventStreamUseCase = connectBadgeEventStreamUseCase, navigateToBadge = { navigator.navigate(BadgeNavKey) }, ) @@ -168,31 +169,35 @@ private fun ObserveGlobalEvents( @Composable private fun ObserveBadgeEvents( + isAuthenticated: Boolean, connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase, navigateToBadge: () -> Unit, ) { val snackbarHostState = LocalSnackbarHostState.current val context = LocalContext.current - LaunchedEffect(connectBadgeEventStreamUseCase) { - connectBadgeEventStreamUseCase().collect { event -> - val message = - event.message - ?.takeIf(String::isNotBlank) - ?: event.badgeName + LaunchedEffect(connectBadgeEventStreamUseCase, isAuthenticated) { + if (!isAuthenticated) return@LaunchedEffect + + connectBadgeEventStreamUseCase() + .collect { event -> + val message = + event.message ?.takeIf(String::isNotBlank) - ?.let { badgeName -> - context.getString(R.string.app_badge_event_message_with_name, badgeName) - } - ?: context.getString(R.string.app_badge_event_message) - - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showPrezelSnackbar( - message = message, - actionLabel = context.getString(R.string.app_badge_event_action), - onAction = navigateToBadge, - ) - } + ?: event.badgeName + ?.takeIf(String::isNotBlank) + ?.let { badgeName -> + context.getString(R.string.app_badge_event_message_with_name, badgeName) + } + ?: context.getString(R.string.app_badge_event_message) + + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar( + message = message, + actionLabel = context.getString(R.string.app_badge_event_action), + onAction = navigateToBadge, + ) + } } } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt index 312ed5f1..3cc9b465 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope import com.team.prezel.core.data.NetworkMonitor import com.team.prezel.core.navigation.NavigationState import com.team.prezel.core.navigation.rememberNavigationState +import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.feature.splash.api.SplashNavKey import com.team.prezel.navigation.MAIN_NAV_KEYS import com.team.prezel.navigation.TOP_LEVEL_KEYS @@ -45,6 +46,9 @@ class PrezelAppState( coroutineScope: CoroutineScope, networkMonitor: NetworkMonitor, ) { + val isAuthenticated + get() = navigationState.currentKey !in UNAUTHENTICATED_NAV_KEYS + val shouldShowNavigationBar get() = navigationState.currentKey in MAIN_NAV_KEYS @@ -57,3 +61,8 @@ class PrezelAppState( initialValue = false, ) } + +private val UNAUTHENTICATED_NAV_KEYS = setOf( + SplashNavKey, + LoginNavKey, +) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt index 48ad1076..512e8c5c 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt @@ -41,7 +41,7 @@ private fun GetBadgeResponse.toDomain(): Badge = badgeName = badgeName, isUnlocked = isUnlocked, imageUrl = imageUrl, - unlockedAt = unlockedAt.takeIf(String::isNotBlank), + unlockedAt = unlockedAt, ) private fun GetBadgeDetailResponse.toDomain(): BadgeDetail = @@ -52,13 +52,15 @@ private fun GetBadgeDetailResponse.toDomain(): BadgeDetail = detailDescription = detailDescription, imageUrl = imageUrl, isUnlocked = isUnlocked, - unlockedAt = unlockedAt.takeIf(String::isNotBlank), + unlockedAt = unlockedAt, ) private fun BadgeEventResponse.toDomain(): BadgeEvent = BadgeEvent( badgeCode = badgeCode, badgeName = badgeName, + introduction = introduction, + imageUrl = imageUrl, message = message, rawData = rawData, ) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt index c813868e..894c617c 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/Badge.kt @@ -21,6 +21,8 @@ data class BadgeDetail( data class BadgeEvent( val badgeCode: String? = null, val badgeName: String? = null, + val introduction: String? = null, + val imageUrl: String? = null, val message: String? = null, val rawData: String? = null, ) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt index 654d1ae8..26f4c2b4 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt @@ -27,6 +27,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.sse.SSE import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType @@ -38,6 +39,7 @@ import kotlinx.serialization.json.Json import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds @Singleton internal class HttpClientFactory @Inject constructor( @@ -59,6 +61,7 @@ internal class HttpClientFactory @Inject constructor( installUserAgent() installLogging() installAuth() + installSse() }, ): HttpClient = HttpClient(OkHttp) { block() } @@ -113,6 +116,13 @@ internal class HttpClientFactory @Inject constructor( } } + internal fun HttpClientConfig<*>.installSse() { + install(SSE) { + maxReconnectionAttempts = Int.MAX_VALUE + reconnectionTime = 1.seconds + } + } + private suspend fun RefreshTokensParams.refreshBearerTokens(): BearerTokens? { val refreshToken = oldTokens?.refreshToken ?: return null diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt index c224b3a9..be807600 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt @@ -1,132 +1,69 @@ package com.team.prezel.core.network.datasource +import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.model.badge.BadgeEventResponse import com.team.prezel.core.network.model.badge.GetBadgeDetailResponse import com.team.prezel.core.network.model.badge.GetBadgeResponse import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.service.BadgeService import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.serverSentEvents +import io.ktor.client.plugins.timeout import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsChannel import io.ktor.http.ContentType -import io.ktor.utils.io.readUTF8Line import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds internal class BadgeRemoteDataSourceImpl @Inject constructor( private val badgeService: BadgeService, private val httpClient: HttpClient, ) : BadgeRemoteDataSource { - private val sseJson: Json = - Json { - ignoreUnknownKeys = true - } - override suspend fun getBadges(): List = badgeService.getBadges().requireData() override suspend fun getBadgeDetail(badgeCode: String): GetBadgeDetailResponse = badgeService.getBadgeDetail(badgeCode = badgeCode).requireData() override fun connectBadgeEventStream(): Flow = callbackFlow { - val response = httpClient.get("api/stream/badges") { - accept(ContentType.Text.EventStream) - } - - val readerJob = launch { - try { - response.readBadgeEvents { event -> - trySend(event) + val streamJob = + launch { + try { + httpClient.serverSentEvents( + urlString = badgeStreamUrl, + request = { + accept(ContentType.Text.EventStream) + timeout { + socketTimeoutMillis = BADGE_SSE_SOCKET_TIMEOUT_MILLIS + } + }, + reconnectionTime = BADGE_SSE_RETRY_DELAY_MILLIS.milliseconds, + ) { + incoming.collect { event -> + BadgeSseEventParser + .parse(eventName = event.event, data = event.data) + ?.let { badgeEvent -> trySend(badgeEvent) } + } + } + close() + } catch (throwable: CancellationException) { + throw throwable + } catch (throwable: Throwable) { + close(throwable) } - close() - } catch (throwable: CancellationException) { - throw throwable - } catch (throwable: Throwable) { - close(throwable) } - } awaitClose { - readerJob.cancel() + streamJob.cancel() } } - private suspend fun HttpResponse.readBadgeEvents(emit: suspend (BadgeEventResponse) -> Unit) { - val channel = bodyAsChannel() - var eventName: String? = null - val dataBuffer = StringBuilder() - - while (!channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: break - Timber.tag("TEST").i("Event: $line") - - when { - line.isBlank() -> { - dispatchBadgeEvent( - eventName = eventName, - data = dataBuffer.toString(), - emit = emit, - ) - eventName = null - dataBuffer.clear() - } - - line.startsWith("event:") -> { - eventName = line.substringAfter("event:").trim().takeIf(String::isNotBlank) - } - - line.startsWith("data:") -> { - if (dataBuffer.isNotEmpty()) { - dataBuffer.append('\n') - } - dataBuffer.append(line.substringAfter("data:").trimStart()) - } - } - } - - dispatchBadgeEvent( - eventName = eventName, - data = dataBuffer.toString(), - emit = emit, - ) + private companion object { + const val BADGE_SSE_SOCKET_TIMEOUT_MILLIS = 90_000L + const val BADGE_SSE_RETRY_DELAY_MILLIS = 1_000L + val badgeStreamUrl = "${BuildConfig.BASE_URL.trimEnd('/')}/api/stream/badges" } - - private suspend fun dispatchBadgeEvent( - eventName: String?, - data: String, - emit: suspend (BadgeEventResponse) -> Unit, - ) { - val rawData = data.trim() - if (rawData.isBlank()) return - - emit(rawData.toBadgeEventResponse(eventName = eventName)) - } - - private fun String.toBadgeEventResponse(eventName: String?): BadgeEventResponse { - val payload = runCatching { - sseJson.decodeFromString(this) - }.getOrNull() - - return BadgeEventResponse( - badgeCode = payload?.badgeCode, - badgeName = payload?.badgeName, - message = payload?.message ?: eventName, - rawData = this, - ) - } - - @Serializable - private data class BadgeEventPayload( - val badgeCode: String? = null, - val badgeName: String? = null, - val message: String? = null, - ) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeSseEventParser.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeSseEventParser.kt new file mode 100644 index 00000000..911db601 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeSseEventParser.kt @@ -0,0 +1,58 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.badge.BadgeEventResponse +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import timber.log.Timber + +internal object BadgeSseEventParser { + private val sseJson: Json = + Json { + ignoreUnknownKeys = true + } + + fun parse( + eventName: String?, + data: String?, + ): BadgeEventResponse? { + Timber.tag("SSE-TEST").i("$eventName: $data") + val rawData = data?.trim().orEmpty() + if (rawData.isBlank()) return null + + return when (eventName) { + SSE_EVENT_BADGE_UNLOCKED -> { + val payload = runCatching { + sseJson.decodeFromString(rawData) + }.getOrNull() ?: return null + + BadgeEventResponse( + badgeCode = payload.badgeCode, + badgeName = payload.badgeName, + introduction = payload.introduction, + imageUrl = payload.imageUrl, + message = payload.message, + rawData = rawData, + ) + } + + SSE_EVENT_CONNECT, + SSE_EVENT_PING, + -> null + + else -> null + } + } + + @Serializable + private data class BadgeEventPayload( + val badgeCode: String? = null, + val badgeName: String? = null, + val introduction: String? = null, + val imageUrl: String? = null, + val message: String? = null, + ) + + private const val SSE_EVENT_CONNECT = "connect" + private const val SSE_EVENT_PING = "ping" + private const val SSE_EVENT_BADGE_UNLOCKED = "badge_unlocked" +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt index db2b7f67..1f37ba9f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt @@ -9,6 +9,10 @@ data class BadgeEventResponse( val badgeCode: String? = null, @SerialName("badgeName") val badgeName: String? = null, + @SerialName("introduction") + val introduction: String? = null, + @SerialName("imageUrl") + val imageUrl: String? = null, @SerialName("message") val message: String? = null, val rawData: String? = null, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt index 199b040b..befe6f8f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeDetailResponse.kt @@ -18,5 +18,5 @@ data class GetBadgeDetailResponse( @SerialName("isUnlocked") val isUnlocked: Boolean, @SerialName("unlockedAt") - val unlockedAt: String, + val unlockedAt: String?, ) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt index a8685904..50977467 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/GetBadgeResponse.kt @@ -14,5 +14,5 @@ data class GetBadgeResponse( @SerialName("isUnlocked") val isUnlocked: Boolean, @SerialName("unlockedAt") - val unlockedAt: String, + val unlockedAt: String?, ) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt index 38bea7a8..1b5c0227 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt @@ -11,6 +11,7 @@ import com.team.prezel.feature.badge.impl.contract.BadgeUiIntent import com.team.prezel.feature.badge.impl.contract.BadgeUiState import com.team.prezel.feature.badge.impl.model.BadgeUiMessage import com.team.prezel.feature.badge.impl.model.BadgeUiModel +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -18,6 +19,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import javax.inject.Inject +@HiltViewModel internal class BadgeViewModel @Inject constructor( private val fetchBadgesUseCase: FetchBadgesUseCase, private val fetchBadgeDetailUseCase: FetchBadgeDetailUseCase, diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt index 09809efb..e8ab4cbd 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt @@ -12,5 +12,5 @@ internal data class BadgeUiState( val badges: ImmutableList = persistentListOf(), val selectedBadgeCode: String? = null, ) : UiState { - val selectedBadge: BadgeUiModel = badges.first { badge -> badge.badgeCode == selectedBadgeCode } + val selectedBadge: BadgeUiModel? = badges.firstOrNull { badge -> badge.badgeCode == selectedBadgeCode } } From a53f12091e0bb6776bf1e77e50d40918e94cf276 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 01:07:59 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=ED=9A=8D?= =?UTF-8?q?=EB=93=9D=20=EC=95=8C=EB=A6=BC=20=EC=8A=A4=EB=82=B5=EB=B0=94=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 배지 알림 스낵바 위치 조정 로직 추가** * `ObserveBadgeEvents`에서 하단 내비게이션 바 표시 여부(`shouldShowNavigationBar`)를 파악하도록 수정했습니다. * 스낵바 호출 시 `useRaisedPosition` 파라미터에 해당 상태를 전달하여, 내비게이션 바와 스낵바가 겹치지 않도록 위치를 조정했습니다. * `LaunchedEffect`의 키 값에서 불필요한 UseCase 의존성을 제거하고 `isAuthenticated` 변화에만 반응하도록 최적화했습니다. * **refactor: `BadgeRemoteDataSourceImpl` 내 상수 가시성 제한** * 외부에서 참조되지 않는 `BADGE_SSE_SOCKET_TIMEOUT_MILLIS`, `BADGE_SSE_RETRY_DELAY_MILLIS`, `badgeStreamUrl` 상수를 `private`으로 변경했습니다. * **style: 배지 알림 액션 문구 수정** * 스낵바의 확인 버튼 텍스트를 `보기`에서 `보러가기`로 변경하여 사용자 행동 유도를 강화했습니다. --- Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt | 5 ++++- Prezel/app/src/main/res/values/strings.xml | 2 +- .../core/network/datasource/BadgeRemoteDataSourceImpl.kt | 6 +++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 8fd70ada..378a68d2 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -107,6 +107,7 @@ private fun PrezelAppContent( ObserveBadgeEvents( isAuthenticated = appState.isAuthenticated, connectBadgeEventStreamUseCase = connectBadgeEventStreamUseCase, + shouldShowNavigationBar = appState.shouldShowNavigationBar, navigateToBadge = { navigator.navigate(BadgeNavKey) }, ) @@ -260,12 +261,13 @@ private tailrec fun Context.findActivity(): Activity? = private fun ObserveBadgeEvents( isAuthenticated: Boolean, connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase, + shouldShowNavigationBar: Boolean, navigateToBadge: () -> Unit, ) { val snackbarHostState = LocalSnackbarHostState.current val resources = LocalResources.current - LaunchedEffect(connectBadgeEventStreamUseCase, isAuthenticated) { + LaunchedEffect(isAuthenticated) { if (!isAuthenticated) return@LaunchedEffect connectBadgeEventStreamUseCase() @@ -285,6 +287,7 @@ private fun ObserveBadgeEvents( message = message, actionLabel = resources.getString(R.string.app_badge_event_action), onAction = navigateToBadge, + useRaisedPosition = shouldShowNavigationBar, ) } } diff --git a/Prezel/app/src/main/res/values/strings.xml b/Prezel/app/src/main/res/values/strings.xml index 2a6184e5..4f91b4ba 100644 --- a/Prezel/app/src/main/res/values/strings.xml +++ b/Prezel/app/src/main/res/values/strings.xml @@ -8,5 +8,5 @@ 한 번 더 누르면 앱을 종료합니다. 새 배지를 획득했어요. %1$s 배지를 획득했어요. - 보기 + 보러가기 diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt index be807600..7e5a77ae 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt @@ -62,8 +62,8 @@ internal class BadgeRemoteDataSourceImpl @Inject constructor( } private companion object { - const val BADGE_SSE_SOCKET_TIMEOUT_MILLIS = 90_000L - const val BADGE_SSE_RETRY_DELAY_MILLIS = 1_000L - val badgeStreamUrl = "${BuildConfig.BASE_URL.trimEnd('/')}/api/stream/badges" + private const val BADGE_SSE_SOCKET_TIMEOUT_MILLIS = 90_000L + private const val BADGE_SSE_RETRY_DELAY_MILLIS = 1_000L + private val badgeStreamUrl = "${BuildConfig.BASE_URL.trimEnd('/')}/api/stream/badges" } } From 940ad58bcbae7473ce8c48aab8d6b56bb270f307 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 15:17:14 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EB=B1=83=EC=A7=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20UI=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 뱃지 상세 모달 및 관련 UI 컴포넌트 추가** * 뱃지 이미지의 잠금/에러 상태를 처리하는 `BadgeDetailImage` 컴포넌트를 추가했습니다. * 뱃지 획득 조건 및 설명을 표시하는 `BadgeDetailInfo` 컴포넌트를 추가했습니다. * 상세 정보를 화면 전체에 표시하는 `BadgeDetailModal`을 구현하고, `BadgeScreen`에 연동했습니다. * **feat: 뱃지 상세 정보 비동기 로딩 및 캐싱 로직 구현** * `BadgeViewModel` 내에 `badgeDetailCache`를 도입하여 한 번 조회한 상세 정보는 다시 서버에 요청하지 않도록 개선했습니다. * 뱃지 클릭 시 상세 정보를 가져오는 `fetchBadgeDetail` 로직과 모달 닫기 처리를 위한 `DismissBadgeDetail` 인텐트를 추가했습니다. * 상세 정보 로딩 상태(`isBadgeDetailLoading`)와 실패 시 스낵바 메시지 처리를 추가했습니다. * **refactor: 뱃지 화면 컴포넌트 분리 및 데이터 모델 최적화** * 기존 `BadgeScreen`의 리스트 표시 로직을 `BadgeListContent`로 분리하여 가독성을 높였습니다. * `BadgeUiModel`에서 상세 설명 필드를 제거하고, 상세 조회 전용 모델인 `BadgeDetailUiModel`을 정의하여 데이터 구조를 최적화했습니다. * 프리뷰 데이터 관리를 위한 `BadgePreviewData.kt`를 추가했습니다. * **style: 문자열 포맷팅 및 가독성 개선** * 뱃지 상세 설명의 마침표(`.`) 뒤에 줄바꿈(`\n`)을 추가하여 텍스트 가독성을 높였습니다. --- .../prezel/feature/badge/impl/BadgeScreen.kt | 126 ++++++++---------- .../feature/badge/impl/BadgeViewModel.kt | 103 ++++++++++---- .../badge/impl/component/BadgeDetailImage.kt | 84 ++++++++++++ .../badge/impl/component/BadgeDetailInfo.kt | 58 ++++++++ .../badge/impl/component/BadgeDetailModal.kt | 109 +++++++++++++++ .../badge/impl/component/BadgeListContent.kt | 83 ++++++++++++ .../badge/impl/component/BadgePreviewData.kt | 39 ++++++ .../badge/impl/contract/BadgeUiIntent.kt | 2 + .../badge/impl/contract/BadgeUiState.kt | 3 + .../badge/impl/model/BadgeDetailUiModel.kt | 13 ++ .../badge/impl/model/BadgeUiMessage.kt | 1 + .../feature/badge/impl/model/BadgeUiModel.kt | 2 - 12 files changed, 526 insertions(+), 97 deletions(-) create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailInfo.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgePreviewData.kt create mode 100644 Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeDetailUiModel.kt diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt index 113fbf12..a616d4db 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt @@ -1,33 +1,26 @@ package com.team.prezel.feature.badge.impl -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -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.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.team.prezel.core.designsystem.component.PrezelTopAppBar -import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.ui.component.PrezelBadge +import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.badge.impl.component.BadgeDetailModal +import com.team.prezel.feature.badge.impl.component.BadgeListContent +import com.team.prezel.feature.badge.impl.component.badgeScreenPreviewState +import com.team.prezel.feature.badge.impl.contract.BadgeUiEffect +import com.team.prezel.feature.badge.impl.contract.BadgeUiIntent import com.team.prezel.feature.badge.impl.contract.BadgeUiState -import com.team.prezel.feature.badge.impl.model.BadgeUiModel -import kotlinx.collections.immutable.persistentListOf +import com.team.prezel.feature.badge.impl.model.BadgeUiMessage -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun BadgeScreen( onBack: () -> Unit, @@ -35,82 +28,71 @@ internal fun BadgeScreen( viewModel: BadgeViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = LocalSnackbarHostState.current - BadgeScreenContent( + LaunchedEffect(Unit) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is BadgeUiEffect.ShowMessage -> { + val message = when (effect.message) { + BadgeUiMessage.FETCH_DATA_FAILED -> "뱃지 목록을 불러오지 못했어요." + BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED -> "뱃지 상세 정보를 불러오지 못했어요." + } + snackbarHostState.showPrezelSnackbar(message = message) + } + } + } + } + + BadgeScreenScreen( uiState = uiState, onBack = onBack, + onBadgeClick = { badgeCode -> viewModel.onIntent(BadgeUiIntent.ClickBadge(badgeCode = badgeCode)) }, + onDismissBadgeDetail = { viewModel.onIntent(BadgeUiIntent.DismissBadgeDetail) }, modifier = modifier, ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun BadgeScreenContent( +internal fun BadgeScreenScreen( uiState: BadgeUiState, onBack: () -> Unit, + onBadgeClick: (badgeCode: String) -> Unit, + onDismissBadgeDetail: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier.fillMaxSize(), + Box( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), ) { - PrezelTopAppBar( - title = { Text(text = "나의 뱃지") }, - leadingIcon = { - IconButton(onClick = onBack) { - Icon( - painter = painterResource(PrezelIcons.ChevronLeft), - contentDescription = "뒤로가기", - ) - } - }, + BadgeListContent( + badges = uiState.badges, + onBack = onBack, + onBadgeClick = onBadgeClick, ) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V16, horizontal = PrezelTheme.spacing.V20), - verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), - horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), - overscrollEffect = null, - ) { - items(items = uiState.badges, key = { badge -> badge.badgeCode }) { badge -> - PrezelBadge( - title = badge.badgeName, - url = badge.imageUrl, - isAchieved = badge.isUnlocked, - ) - } + uiState.selectedBadge?.let { badge -> + BadgeDetailModal( + badge = badge, + badgeDetail = uiState.selectedBadgeDetail, + isLoading = uiState.isBadgeDetailLoading, + onDismiss = onDismissBadgeDetail, + modifier = Modifier.fillMaxSize(), + ) } } } -@Preview(showBackground = true) +@BasicPreview @Composable private fun BadgeScreenPreview() { PrezelTheme { - BadgeScreenContent( - uiState = BadgeUiState( - badges = persistentListOf( - BadgeUiModel( - badgeCode = "1", - badgeName = "첫 발표", - conditionText = "첫 발표를 완료하세요", - detailDescription = "첫 발표를 완료하면 획득할 수 있습니다.", - imageUrl = "", - isUnlocked = true, - ), - BadgeUiModel( - badgeCode = "2", - badgeName = "분석 왕", - conditionText = "발표 분석을 10번 완료하세요", - detailDescription = "발표 분석을 10번 완료하면 획득할 수 있습니다.", - imageUrl = "", - isUnlocked = false, - ), - ), - selectedBadgeCode = "1", - ), + BadgeScreenScreen( + uiState = badgeScreenPreviewState(), onBack = {}, + onBadgeClick = {}, + onDismissBadgeDetail = {}, ) } } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt index 1b5c0227..4c22d83b 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt @@ -9,13 +9,11 @@ import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.badge.impl.contract.BadgeUiEffect import com.team.prezel.feature.badge.impl.contract.BadgeUiIntent import com.team.prezel.feature.badge.impl.contract.BadgeUiState +import com.team.prezel.feature.badge.impl.model.BadgeDetailUiModel import com.team.prezel.feature.badge.impl.model.BadgeUiMessage import com.team.prezel.feature.badge.impl.model.BadgeUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -24,43 +22,102 @@ internal class BadgeViewModel @Inject constructor( private val fetchBadgesUseCase: FetchBadgesUseCase, private val fetchBadgeDetailUseCase: FetchBadgeDetailUseCase, ) : BaseViewModel(BadgeUiState()) { + private val badgeDetailCache = mutableMapOf() + init { fetchData() } override fun onIntent(intent: BadgeUiIntent) { when (intent) { - is BadgeUiIntent.ClickBadge -> {} + is BadgeUiIntent.ClickBadge -> fetchBadgeDetail(intent.badgeCode) + BadgeUiIntent.DismissBadgeDetail -> dismissBadgeDetail() } } private fun fetchData() { viewModelScope.launch { + updateState { copy(isLoading = true) } fetchBadgesUseCase() .onSuccess { badges -> handleFetchDataSuccess(badges = badges) } - .onFailure { sendEffect(BadgeUiEffect.ShowMessage(BadgeUiMessage.FETCH_DATA_FAILED)) } + .onFailure { + updateState { copy(isLoading = false) } + sendEffect(BadgeUiEffect.ShowMessage(BadgeUiMessage.FETCH_DATA_FAILED)) + } + } + } + + private fun handleFetchDataSuccess(badges: List) { + updateState { + copy( + isLoading = false, + badges = badges.map { badge -> badge.toUiModel() }.toImmutableList(), + ) } } - private suspend fun handleFetchDataSuccess(badges: List) = - coroutineScope { - val badgeDetails = badges - .map { badge -> async { fetchBadgeDetailUseCase(badgeCode = badge.badgeCode) } } - .awaitAll() - .mapNotNull(Result::getOrNull) - .map { detail -> - with(detail) { - BadgeUiModel( - badgeCode = badgeCode, - badgeName = badgeName, - conditionText = conditionText, - detailDescription = detailDescription, - imageUrl = imageUrl, - isUnlocked = isUnlocked, - ) + private fun fetchBadgeDetail(badgeCode: String) { + val selectedBadge = currentState.badges.firstOrNull { badge -> badge.badgeCode == badgeCode } ?: return + val cachedDetail = badgeDetailCache[badgeCode] + + updateState { + copy( + selectedBadgeCode = selectedBadge.badgeCode, + selectedBadgeDetail = cachedDetail, + isBadgeDetailLoading = cachedDetail == null, + ) + } + + if (cachedDetail != null) return + + viewModelScope.launch { + fetchBadgeDetailUseCase(badgeCode = badgeCode) + .onSuccess { detail -> + val detailUiModel = detail.toUiModel() + badgeDetailCache[badgeCode] = detailUiModel + + if (currentState.selectedBadgeCode == badgeCode) { + updateState { + copy( + selectedBadgeDetail = detailUiModel, + isBadgeDetailLoading = false, + ) + } } - }.toImmutableList() + }.onFailure { + if (currentState.selectedBadgeCode == badgeCode) { + dismissBadgeDetail() + } + sendEffect(BadgeUiEffect.ShowMessage(BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED)) + } + } + } - updateState { copy(badges = badgeDetails) } + private fun dismissBadgeDetail() { + updateState { + copy( + selectedBadgeCode = null, + selectedBadgeDetail = null, + isBadgeDetailLoading = false, + ) } + } + + private fun Badge.toUiModel(): BadgeUiModel = + BadgeUiModel( + badgeCode = badgeCode, + badgeName = badgeName, + imageUrl = imageUrl, + isUnlocked = isUnlocked, + ) + + private fun BadgeDetail.toUiModel(): BadgeDetailUiModel = + BadgeDetailUiModel( + badgeCode = badgeCode, + badgeName = badgeName, + conditionText = conditionText, + detailDescription = detailDescription.replace(".", ".\n"), + imageUrl = imageUrl, + isUnlocked = isUnlocked, + ) } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt new file mode 100644 index 00000000..4e790904 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt @@ -0,0 +1,84 @@ +package com.team.prezel.feature.badge.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.PrezelAsyncImage +import com.team.prezel.core.designsystem.component.image.PrezelImage +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun BadgeDetailImage( + imageUrl: String, + isUnlocked: Boolean, + modifier: Modifier = Modifier, +) { + var isError by remember(imageUrl) { mutableStateOf(false) } + + Box( + modifier = modifier + .aspectRatio(1f) + .clip(PrezelTheme.shapes.V16), + ) { + PrezelAsyncImage( + url = imageUrl, + contentDescription = "", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + onError = { isError = true }, + onSuccess = { isError = false }, + ) + + if (isError) { + PrezelImage( + resId = PrezelIcons.WarningCircleOutlined, + contentDescription = "", + modifier = Modifier.fillMaxSize(), + ) + return@Box + } + + if (!isUnlocked) { + Box( + modifier = Modifier + .fillMaxSize() + .background(PrezelTheme.colors.scrimContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(PrezelIcons.Lock), + contentDescription = null, + tint = PrezelTheme.colors.solidWhite, + modifier = Modifier.size(56.dp), + ) + } + } + } +} + +@BasicPreview +@Composable +private fun BadgeDetailImagePreview() { + PrezelTheme { + BadgeDetailImage( + imageUrl = "", + isUnlocked = false, + ) + } +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailInfo.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailInfo.kt new file mode 100644 index 00000000..cd152834 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailInfo.kt @@ -0,0 +1,58 @@ +package com.team.prezel.feature.badge.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.badge.impl.model.BadgeDetailUiModel + +@Composable +internal fun BadgeDetailInfo( + detail: BadgeDetailUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .clip(PrezelTheme.shapes.V4) + .background(PrezelTheme.colors.interactiveXSmall) + .padding(horizontal = PrezelTheme.spacing.V6, vertical = PrezelTheme.spacing.V4), + ) { + Text( + text = detail.conditionText, + style = PrezelTheme.typography.caption2Regular, + color = PrezelTheme.colors.interactiveRegular, + ) + } + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + + Text( + text = detail.detailDescription, + style = PrezelTheme.typography.body2Regular, + color = PrezelTheme.colors.textLarge, + textAlign = TextAlign.Center, + ) + } +} + +@BasicPreview +@Composable +private fun BadgeDetailInfoPreview() { + PrezelTheme { + BadgeDetailInfo(detail = badgePreviewDetail()) + } +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt new file mode 100644 index 00000000..3df914fd --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt @@ -0,0 +1,109 @@ +package com.team.prezel.feature.badge.impl.component + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.material3.CircularProgressIndicator +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +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.core.ui.util.noRippleClickable +import com.team.prezel.feature.badge.impl.model.BadgeDetailUiModel +import com.team.prezel.feature.badge.impl.model.BadgeUiModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BadgeDetailModal( + badge: BadgeUiModel, + badgeDetail: BadgeDetailUiModel?, + isLoading: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onDismiss) + + Column( + modifier = modifier + .background(PrezelTheme.colors.bgRegular) + .noRippleClickable { /* 클릭 이벤트 소비를 위함 */ }, + ) { + PrezelTopAppBar( + trailingIcons = { + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = "닫기", + ) + } + }, + ) + + Spacer(Modifier.weight(72f)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(376f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BadgeDetailImage( + imageUrl = badge.imageUrl, + isUnlocked = badge.isUnlocked, + modifier = Modifier.fillMaxWidth(0.65f), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + + Text( + text = badge.badgeName, + style = PrezelTheme.typography.title1Bold, + color = PrezelTheme.colors.textLarge, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + + if (isLoading) { + CircularProgressIndicator( + color = PrezelTheme.colors.interactiveRegular, + modifier = Modifier.padding(top = PrezelTheme.spacing.V12), + ) + } else { + badgeDetail?.let { detail -> + BadgeDetailInfo(detail = detail) + } + } + } + Spacer(Modifier.weight(180f)) + } +} + +@BasicPreview +@Composable +private fun BadgeDetailModalPreview() { + PrezelTheme { + BadgeDetailModal( + badge = badgePreviewBadges().first(), + badgeDetail = badgePreviewDetail(), + isLoading = false, + onDismiss = {}, + modifier = Modifier.fillMaxSize(), + ) + } +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt new file mode 100644 index 00000000..4fe1919c --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt @@ -0,0 +1,83 @@ +package com.team.prezel.feature.badge.impl.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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.core.ui.component.PrezelBadge +import com.team.prezel.feature.badge.impl.model.BadgeUiModel +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BadgeListContent( + badges: ImmutableList, + onBack: () -> Unit, + onBadgeClick: (badgeCode: String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize()) { + PrezelTopAppBar( + title = { Text(text = "나의 뱃지") }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ChevronLeft), + contentDescription = "뒤로가기", + ) + } + }, + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V16, horizontal = PrezelTheme.spacing.V20), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + overscrollEffect = null, + ) { + items(items = badges, key = { badge -> badge.badgeCode }) { badge -> + PrezelBadge( + title = badge.badgeName, + url = badge.imageUrl, + isAchieved = badge.isUnlocked, + modifier = Modifier.clickable( + onClick = { onBadgeClick(badge.badgeCode) }, + indication = ripple(color = PrezelTheme.colors.bgMedium), + interactionSource = null, + ), + ) + } + } + } +} + +@BasicPreview +@Composable +private fun BadgeListContentPreview() { + PrezelTheme { + BadgeListContent( + badges = badgePreviewBadges(), + onBack = {}, + onBadgeClick = {}, + ) + } +} diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgePreviewData.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgePreviewData.kt new file mode 100644 index 00000000..2d00ade0 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgePreviewData.kt @@ -0,0 +1,39 @@ +package com.team.prezel.feature.badge.impl.component + +import com.team.prezel.feature.badge.impl.contract.BadgeUiState +import com.team.prezel.feature.badge.impl.model.BadgeDetailUiModel +import com.team.prezel.feature.badge.impl.model.BadgeUiModel +import kotlinx.collections.immutable.persistentListOf + +internal fun badgePreviewBadges() = + persistentListOf( + BadgeUiModel( + badgeCode = "1", + badgeName = "첫 발표", + imageUrl = "", + isUnlocked = true, + ), + BadgeUiModel( + badgeCode = "2", + badgeName = "분석 왕", + imageUrl = "", + isUnlocked = false, + ), + ) + +internal fun badgePreviewDetail() = + BadgeDetailUiModel( + badgeCode = "1", + badgeName = "첫 발표", + conditionText = "첫 발표 등록하기", + detailDescription = "첫 발표를 등록하며 연습을 시작했어요.\n나의 발표 여정의 첫 걸음이에요.", + imageUrl = "", + isUnlocked = false, + ) + +internal fun badgeScreenPreviewState() = + BadgeUiState( + badges = badgePreviewBadges(), + selectedBadgeCode = "1", + selectedBadgeDetail = badgePreviewDetail(), + ) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt index 5681adb5..f18b5de9 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt @@ -6,4 +6,6 @@ internal sealed interface BadgeUiIntent : UiIntent { data class ClickBadge( val badgeCode: String, ) : BadgeUiIntent + + data object DismissBadgeDetail : BadgeUiIntent } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt index e8ab4cbd..c6a42a91 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt @@ -2,6 +2,7 @@ package com.team.prezel.feature.badge.impl.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.badge.impl.model.BadgeDetailUiModel import com.team.prezel.feature.badge.impl.model.BadgeUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -11,6 +12,8 @@ internal data class BadgeUiState( val isLoading: Boolean = false, val badges: ImmutableList = persistentListOf(), val selectedBadgeCode: String? = null, + val selectedBadgeDetail: BadgeDetailUiModel? = null, + val isBadgeDetailLoading: Boolean = false, ) : UiState { val selectedBadge: BadgeUiModel? = badges.firstOrNull { badge -> badge.badgeCode == selectedBadgeCode } } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeDetailUiModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeDetailUiModel.kt new file mode 100644 index 00000000..427ed747 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeDetailUiModel.kt @@ -0,0 +1,13 @@ +package com.team.prezel.feature.badge.impl.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class BadgeDetailUiModel( + val badgeCode: String, + val badgeName: String, + val conditionText: String, + val detailDescription: String, + val imageUrl: String, + val isUnlocked: Boolean, +) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt index bb0eb982..bd476b45 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt @@ -2,4 +2,5 @@ package com.team.prezel.feature.badge.impl.model internal enum class BadgeUiMessage { FETCH_DATA_FAILED, + FETCH_BADGE_DETAIL_FAILED, } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt index 7df644fd..16563b27 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt @@ -6,8 +6,6 @@ import androidx.compose.runtime.Immutable internal data class BadgeUiModel( val badgeCode: String, val badgeName: String, - val conditionText: String, - val detailDescription: String, val imageUrl: String, val isUnlocked: Boolean, ) From 9996b53255567cc35d8b18a5a2d204a5ae1a4fd5 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 15:53:13 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=EB=B1=83=EC=A7=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 뱃지 상세 정보 관련 상태 및 로딩 로직 제거** * `BadgeUiState`에서 더 이상 사용하지 않는 `isBadgeDetailLoading` 필드를 제거했습니다. * `BadgeViewModel` 내 뱃지 선택 및 상세 정보 조회 로직에서 로딩 상태를 업데이트하던 코드를 삭제했습니다. * **ui: `BadgeDetailModal` 디자인 및 구조 개선** * 상세 모달에서 `isLoading` 파라미터와 `CircularProgressIndicator`를 제거했습니다. * 뱃지 상세 정보를 효과적으로 보여주기 위해 `BadgeDetailChip`과 `BadgeDetailDescription` 컴포저블을 새롭게 정의했습니다. * 모달 내 레이아웃을 `Alignment.CenterHorizontally` 중심으로 재구성하고, 뱃지 이미지, 이름, 칩, 상세 설명 간의 간격을 디자인 시스템 가이드에 맞춰 조정했습니다. * 뱃지 상세 설명 영역에 `PrezelTheme` 기반의 타이포그래피와 컬러를 적용했습니다. --- .../prezel/feature/badge/impl/BadgeScreen.kt | 1 - .../feature/badge/impl/BadgeViewModel.kt | 9 +- .../badge/impl/component/BadgeDetailModal.kt | 88 +++++++++++++------ .../badge/impl/contract/BadgeUiState.kt | 1 - .../main/component/HomeAnalysisFabOverlay.kt | 3 +- 5 files changed, 66 insertions(+), 36 deletions(-) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt index a616d4db..87b71fdc 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt @@ -76,7 +76,6 @@ internal fun BadgeScreenScreen( BadgeDetailModal( badge = badge, badgeDetail = uiState.selectedBadgeDetail, - isLoading = uiState.isBadgeDetailLoading, onDismiss = onDismissBadgeDetail, modifier = Modifier.fillMaxSize(), ) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt index 4c22d83b..284a8036 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt @@ -64,7 +64,6 @@ internal class BadgeViewModel @Inject constructor( copy( selectedBadgeCode = selectedBadge.badgeCode, selectedBadgeDetail = cachedDetail, - isBadgeDetailLoading = cachedDetail == null, ) } @@ -77,12 +76,7 @@ internal class BadgeViewModel @Inject constructor( badgeDetailCache[badgeCode] = detailUiModel if (currentState.selectedBadgeCode == badgeCode) { - updateState { - copy( - selectedBadgeDetail = detailUiModel, - isBadgeDetailLoading = false, - ) - } + updateState { copy(selectedBadgeDetail = detailUiModel) } } }.onFailure { if (currentState.selectedBadgeCode == badgeCode) { @@ -98,7 +92,6 @@ internal class BadgeViewModel @Inject constructor( copy( selectedBadgeCode = null, selectedBadgeDetail = null, - isBadgeDetailLoading = false, ) } } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt index 3df914fd..f3d52168 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt @@ -9,7 +9,6 @@ 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.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -20,6 +19,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.chip.chip.ChipHierarchy +import com.team.prezel.core.designsystem.component.chip.chip.ChipSize +import com.team.prezel.core.designsystem.component.chip.chip.ChipState +import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -32,7 +35,6 @@ import com.team.prezel.feature.badge.impl.model.BadgeUiModel internal fun BadgeDetailModal( badge: BadgeUiModel, badgeDetail: BadgeDetailUiModel?, - isLoading: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -42,6 +44,7 @@ internal fun BadgeDetailModal( modifier = modifier .background(PrezelTheme.colors.bgRegular) .noRippleClickable { /* 클릭 이벤트 소비를 위함 */ }, + horizontalAlignment = Alignment.CenterHorizontally, ) { PrezelTopAppBar( trailingIcons = { @@ -54,46 +57,82 @@ internal fun BadgeDetailModal( }, ) - Spacer(Modifier.weight(72f)) + Spacer(modifier = Modifier.weight(72f)) + Column( modifier = Modifier .fillMaxWidth() .weight(376f), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { - BadgeDetailImage( - imageUrl = badge.imageUrl, - isUnlocked = badge.isUnlocked, + Column( modifier = Modifier.fillMaxWidth(0.65f), - ) + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BadgeDetailImage( + imageUrl = badge.imageUrl, + isUnlocked = badge.isUnlocked, + modifier = Modifier.fillMaxWidth(), + ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - Text( - text = badge.badgeName, - style = PrezelTheme.typography.title1Bold, - color = PrezelTheme.colors.textLarge, - textAlign = TextAlign.Center, - ) + Text( + text = badge.badgeName, + style = PrezelTheme.typography.title1Bold, + color = PrezelTheme.colors.textLarge, + textAlign = TextAlign.Center, + ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) - if (isLoading) { - CircularProgressIndicator( - color = PrezelTheme.colors.interactiveRegular, - modifier = Modifier.padding(top = PrezelTheme.spacing.V12), - ) - } else { badgeDetail?.let { detail -> - BadgeDetailInfo(detail = detail) + BadgeDetailChip(detail = detail) } } + + badgeDetail?.let { detail -> + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + BadgeDetailDescription( + detail = detail, + modifier = Modifier.padding(horizontal = PrezelTheme.spacing.V24), + ) + } } - Spacer(Modifier.weight(180f)) + + Spacer(modifier = Modifier.weight(180f)) } } +@Composable +private fun BadgeDetailChip( + detail: BadgeDetailUiModel, + modifier: Modifier = Modifier, +) { + PrezelChip( + text = detail.badgeName, + size = ChipSize.SMALL, + hierarchy = ChipHierarchy.PRIMARY, + state = ChipState.ACTIVE, + modifier = modifier, + ) +} + +@Composable +private fun BadgeDetailDescription( + detail: BadgeDetailUiModel, + modifier: Modifier = Modifier, +) { + Text( + text = detail.detailDescription, + style = PrezelTheme.typography.body2Regular, + color = PrezelTheme.colors.textLarge, + textAlign = TextAlign.Center, + modifier = modifier.fillMaxWidth(), + ) +} + @BasicPreview @Composable private fun BadgeDetailModalPreview() { @@ -101,7 +140,6 @@ private fun BadgeDetailModalPreview() { BadgeDetailModal( badge = badgePreviewBadges().first(), badgeDetail = badgePreviewDetail(), - isLoading = false, onDismiss = {}, modifier = Modifier.fillMaxSize(), ) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt index c6a42a91..99627116 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt @@ -13,7 +13,6 @@ internal data class BadgeUiState( val badges: ImmutableList = persistentListOf(), val selectedBadgeCode: String? = null, val selectedBadgeDetail: BadgeDetailUiModel? = null, - val isBadgeDetailLoading: Boolean = false, ) : UiState { val selectedBadge: BadgeUiModel? = badges.firstOrNull { badge -> badge.badgeCode == selectedBadgeCode } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt index 1c80b5c3..bd3d3604 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize @@ -104,7 +105,7 @@ private fun HomeAnalysisFabPopup( collapsedFabBounds: Rect?, expandedMenuBounds: Rect?, fabEndPaddingPx: Int, - density: androidx.compose.ui.unit.Density, + density: Density, isFabExpanded: Boolean, onChangeExpanded: (Boolean) -> Unit, onClickVoiceRecordingAnalysis: () -> Unit, From eb00b265a5b0a7295da89643e2e25a26271b6bf2 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 16:05:51 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=B0=B0=EA=B2=BD=EC=83=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 스플래시 화면 내비게이션 지연 처리 및 가시성 제어 로직 추가** * 화면 전환 시 급격한 UI 변화를 방지하기 위해 500ms의 지연 시간(`SPLASH_NAVIGATION_DELAY_MILLIS`)을 도입했습니다. * `screenVisibility` 상태를 추가하여 내비게이션이 시작될 때 스플래시 콘텐츠를 화면에서 제거하도록 수정했습니다. * `NavigateToHome`, `NavigateToLogin` 등 모든 내비게이션 액션에 `navigateWithDelay` 공통 로직을 적용했습니다. * **style: 마이페이지 배경색 적용** * `MyScreen`의 루트 레이아웃에 `PrezelTheme.colors.bgRegular` 배경색을 추가하여 디자인 일관성을 확보했습니다. --- .../team/prezel/feature/my/impl/MyScreen.kt | 3 +- .../feature/splash/impl/SplashScreen.kt | 32 ++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) 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 index fd071d7f..5f66af12 100644 --- 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 @@ -1,5 +1,6 @@ package com.team.prezel.feature.my.impl +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -75,7 +76,7 @@ private fun MyScreen( modifier: Modifier = Modifier, ) { Column( - modifier = modifier.fillMaxSize(), + modifier = modifier.fillMaxSize().background(PrezelTheme.colors.bgRegular), horizontalAlignment = Alignment.CenterHorizontally, ) { MyTopAppBar(onClickSetting = onClickSetting) diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt index c68e4e27..04bd4391 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt @@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources @@ -23,8 +27,11 @@ import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent +import kotlinx.coroutines.delay import com.team.prezel.core.designsystem.R as DSR +private const val SPLASH_NAVIGATION_DELAY_MILLIS = 500L + @Composable internal fun SharedTransitionScope.SplashScreen( animatedVisibilityScope: AnimatedVisibilityScope, @@ -37,6 +44,13 @@ internal fun SharedTransitionScope.SplashScreen( ) { val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current + var screenVisibility by remember { mutableStateOf(true) } + + suspend fun navigateWithDelay(navigate: () -> Unit) { + delay(SPLASH_NAVIGATION_DELAY_MILLIS) + screenVisibility = false + navigate() + } LaunchedEffect(Unit) { viewModel.onIntent(SplashUiIntent.CheckLoginStatus) @@ -45,10 +59,10 @@ internal fun SharedTransitionScope.SplashScreen( LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { - SplashUiEffect.NavigateToHome -> navigateToHome() - SplashUiEffect.NavigateToLogin -> navigateToLogin() - SplashUiEffect.NavigateToTerms -> navigateToTerms() - SplashUiEffect.NavigateToCreateProfile -> navigateToCreateProfile() + SplashUiEffect.NavigateToHome -> navigateWithDelay(navigateToHome) + SplashUiEffect.NavigateToLogin -> navigateWithDelay(navigateToLogin) + SplashUiEffect.NavigateToTerms -> navigateWithDelay(navigateToTerms) + SplashUiEffect.NavigateToCreateProfile -> navigateWithDelay(navigateToCreateProfile) SplashUiEffect.ShowRetryableFailureMessage -> { snackbarHostState.showPrezelSnackbar( resources.getString(R.string.feature_splash_impl_retryable_failure), @@ -58,10 +72,12 @@ internal fun SharedTransitionScope.SplashScreen( } } - SplashScreen( - animatedVisibilityScope = animatedVisibilityScope, - modifier = modifier, - ) + if (screenVisibility) { + SplashScreen( + animatedVisibilityScope = animatedVisibilityScope, + modifier = modifier, + ) + } } @Composable From 524426528a81bd4a02473a876818b1a54df59218 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 16:07:49 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20BadgeDetailModal=20UI=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `BadgeHeader` 컴포넌트 추출** * `BadgeDetailModal` 내부에 구현되어 있던 뱃지 이미지, 이름, 상태 칩 표시 로직을 별도의 private `@Composable` 함수인 `BadgeHeader`로 분리했습니다. * 메인 모달 레이아웃에서 헤더 영역과 상세 설명 영역의 논리적 구분을 명확히 하여 코드 가독성을 개선했습니다. --- .../badge/impl/component/BadgeDetailModal.kt | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt index f3d52168..fc8ab940 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt @@ -65,32 +65,7 @@ internal fun BadgeDetailModal( .weight(376f), horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = Modifier.fillMaxWidth(0.65f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - BadgeDetailImage( - imageUrl = badge.imageUrl, - isUnlocked = badge.isUnlocked, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - - Text( - text = badge.badgeName, - style = PrezelTheme.typography.title1Bold, - color = PrezelTheme.colors.textLarge, - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) - - badgeDetail?.let { detail -> - BadgeDetailChip(detail = detail) - } - } + BadgeHeader(badge = badge, badgeDetail = badgeDetail) badgeDetail?.let { detail -> Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) @@ -105,6 +80,39 @@ internal fun BadgeDetailModal( } } +@Composable +private fun BadgeHeader( + badge: BadgeUiModel, + badgeDetail: BadgeDetailUiModel?, +) { + Column( + modifier = Modifier.fillMaxWidth(0.65f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BadgeDetailImage( + imageUrl = badge.imageUrl, + isUnlocked = badge.isUnlocked, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + + Text( + text = badge.badgeName, + style = PrezelTheme.typography.title1Bold, + color = PrezelTheme.colors.textLarge, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + + badgeDetail?.let { detail -> + BadgeDetailChip(detail = detail) + } + } +} + @Composable private fun BadgeDetailChip( detail: BadgeDetailUiModel, From 588a8f668f95eb6753efb960e05dcf2fe8c2f58a Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 19:05:17 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=EC=8A=A4=EB=82=B5=EB=B0=94=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B2=B0=EC=A0=95=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20`shouldShowNavigationBar`=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **fix: `LaunchedEffect` 내 상태 캡처 현상 해결을 위한 `rememberUpdatedState` 도입** * `LaunchedEffect` 내부의 비동기 로직에서 `shouldShowNavigationBar` 상태를 참조할 때 최신 값을 보장하기 위해 `rememberUpdatedState`를 사용하도록 수정했습니다. * 이를 통해 하단 네비게이션 바의 가시성 상태가 변경되어도 스낵바의 위치(`useRaisedPosition`)가 올바르게 계산되도록 개선했습니다. --- Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 378a68d2..b1154cd0 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -266,6 +267,7 @@ private fun ObserveBadgeEvents( ) { val snackbarHostState = LocalSnackbarHostState.current val resources = LocalResources.current + val currentShouldShowNavigationBar by rememberUpdatedState(shouldShowNavigationBar) LaunchedEffect(isAuthenticated) { if (!isAuthenticated) return@LaunchedEffect @@ -287,7 +289,7 @@ private fun ObserveBadgeEvents( message = message, actionLabel = resources.getString(R.string.app_badge_event_action), onAction = navigateToBadge, - useRaisedPosition = shouldShowNavigationBar, + useRaisedPosition = currentShouldShowNavigationBar, ) } } From 6ef2f8fbb7163370ca9d3305c567b04f9adbeef8 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 19:12:50 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20=EB=B1=83=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20SSE=20=EC=8A=A4=ED=8A=B8=EB=A6=BC?= =?UTF-8?q?=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `BadgeRemoteDataSourceImpl` 내 스트림 유지 및 재연결 로직 강화** * SSE 스트림 생성 로직을 `createStreamJob` 메서드로 추출하여 가독성을 높였습니다. * `while (isActive)` 루프와 `delay`를 도입하여, `CancellationException` 이외의 에러 발생 시 지정된 시간 이후 자동으로 연결을 재시도하도록 개선했습니다. * 에러 발생 시 `Timber`를 통한 로그 기록을 추가했습니다. * **refactor: `HttpClientFactory` 내 SSE 기본 설정 변경** * `maxReconnectionAttempts`를 `Int.MAX_VALUE`에서 `10`으로 하향 조정했습니다. * `reconnectionTime`을 `1.seconds`에서 `5.seconds`로 변경하여 재연결 간격을 늘렸습니다. * **style: `BadgeEventResponse` 모델 개선** * `rawData` 프로퍼티에 명시적으로 `@SerialName("rawData")` 어노테이션을 추가했습니다. --- .../core/network/client/HttpClientFactory.kt | 4 +- .../datasource/BadgeRemoteDataSourceImpl.kt | 62 +++++++++++-------- .../network/model/badge/BadgeEventResponse.kt | 1 + 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt index 26f4c2b4..53fe90d2 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt @@ -118,8 +118,8 @@ internal class HttpClientFactory @Inject constructor( internal fun HttpClientConfig<*>.installSse() { install(SSE) { - maxReconnectionAttempts = Int.MAX_VALUE - reconnectionTime = 1.seconds + maxReconnectionAttempts = 10 + reconnectionTime = 5.seconds } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt index 7e5a77ae..a10e2621 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt @@ -12,10 +12,15 @@ import io.ktor.client.plugins.timeout import io.ktor.client.request.accept import io.ktor.http.ContentType import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -29,38 +34,43 @@ internal class BadgeRemoteDataSourceImpl @Inject constructor( override fun connectBadgeEventStream(): Flow = callbackFlow { - val streamJob = - launch { - try { - httpClient.serverSentEvents( - urlString = badgeStreamUrl, - request = { - accept(ContentType.Text.EventStream) - timeout { - socketTimeoutMillis = BADGE_SSE_SOCKET_TIMEOUT_MILLIS - } - }, - reconnectionTime = BADGE_SSE_RETRY_DELAY_MILLIS.milliseconds, - ) { - incoming.collect { event -> - BadgeSseEventParser - .parse(eventName = event.event, data = event.data) - ?.let { badgeEvent -> trySend(badgeEvent) } - } - } - close() - } catch (throwable: CancellationException) { - throw throwable - } catch (throwable: Throwable) { - close(throwable) - } - } + val streamJob = createStreamJob() awaitClose { streamJob.cancel() } } + private fun ProducerScope.createStreamJob(): Job = + launch { + while (isActive) { + try { + httpClient.serverSentEvents( + urlString = badgeStreamUrl, + reconnectionTime = BADGE_SSE_RETRY_DELAY_MILLIS.milliseconds, + request = { + accept(ContentType.Text.EventStream) + timeout { socketTimeoutMillis = BADGE_SSE_SOCKET_TIMEOUT_MILLIS } + }, + ) { + incoming.collect { event -> + BadgeSseEventParser + .parse( + eventName = event.event, + data = event.data, + )?.let { badgeEvent -> trySend(badgeEvent) } + } + } + close() + } catch (throwable: CancellationException) { + throw throwable + } catch (throwable: Throwable) { + Timber.e(throwable) + delay(BADGE_SSE_RETRY_DELAY_MILLIS) + } + } + } + private companion object { private const val BADGE_SSE_SOCKET_TIMEOUT_MILLIS = 90_000L private const val BADGE_SSE_RETRY_DELAY_MILLIS = 1_000L diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt index 1f37ba9f..2fda7f66 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt @@ -15,5 +15,6 @@ data class BadgeEventResponse( val imageUrl: String? = null, @SerialName("message") val message: String? = null, + @SerialName("rawData") val rawData: String? = null, ) From c90fb812b96dbef653569be72de009681a304ac0 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 19:20:01 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20badge=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=20=EB=A6=AC=EC=86=8C=EC=8A=A4=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?UI=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `feature:badge:impl` 모듈 내 문자열 리소스 관리** * `strings.xml`을 신규 생성하여 화면 타이틀, 내비게이션 아이콘 설명, 데이터 요청 실패 메시지 등을 정의했습니다. * `BadgeScreen`에서 `BadgeUiMessage`에 따라 스낵바를 표시할 때 하드코딩된 문자열 대신 리소스에서 가져온 메시지를 사용하도록 변경했습니다. * `BadgeListContent` 및 `BadgeDetailModal`의 TopAppBar 타이틀과 아이콘 `contentDescription`에 `stringResource`를 적용했습니다. * **refactor: `BadgeScreen` 내 `LaunchedEffect` 종속성 업데이트** * UI Effect를 수집하는 `LaunchedEffect`의 키에 `viewModel`과 새로 정의된 문자열 리소스 변수들을 추가하여 안정성을 높였습니다. --- .../com/team/prezel/feature/badge/impl/BadgeScreen.kt | 10 +++++++--- .../feature/badge/impl/component/BadgeDetailModal.kt | 4 +++- .../feature/badge/impl/component/BadgeListContent.kt | 6 ++++-- .../feature/badge/impl/src/main/res/values/strings.xml | 10 ++++++++++ 4 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 Prezel/feature/badge/impl/src/main/res/values/strings.xml diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt index 87b71fdc..b54ca861 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt @@ -7,12 +7,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.badge.impl.R import com.team.prezel.feature.badge.impl.component.BadgeDetailModal import com.team.prezel.feature.badge.impl.component.BadgeListContent import com.team.prezel.feature.badge.impl.component.badgeScreenPreviewState @@ -29,14 +31,16 @@ internal fun BadgeScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = LocalSnackbarHostState.current + val fetchDataFailedMessage = stringResource(R.string.feature_badge_impl_message_fetch_badges_failed) + val fetchBadgeDetailFailedMessage = stringResource(R.string.feature_badge_impl_message_fetch_badge_detail_failed) - LaunchedEffect(Unit) { + LaunchedEffect(viewModel, fetchDataFailedMessage, fetchBadgeDetailFailedMessage) { viewModel.uiEffect.collect { effect -> when (effect) { is BadgeUiEffect.ShowMessage -> { val message = when (effect.message) { - BadgeUiMessage.FETCH_DATA_FAILED -> "뱃지 목록을 불러오지 못했어요." - BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED -> "뱃지 상세 정보를 불러오지 못했어요." + BadgeUiMessage.FETCH_DATA_FAILED -> fetchDataFailedMessage + BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED -> fetchBadgeDetailFailedMessage } snackbarHostState.showPrezelSnackbar(message = message) } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt index fc8ab940..6657ac08 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.team.prezel.core.designsystem.component.PrezelTopAppBar import com.team.prezel.core.designsystem.component.chip.chip.ChipHierarchy @@ -27,6 +28,7 @@ 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.ui.util.noRippleClickable +import com.team.prezel.feature.badge.impl.R import com.team.prezel.feature.badge.impl.model.BadgeDetailUiModel import com.team.prezel.feature.badge.impl.model.BadgeUiModel @@ -51,7 +53,7 @@ internal fun BadgeDetailModal( IconButton(onClick = onDismiss) { Icon( painter = painterResource(PrezelIcons.Cancel), - contentDescription = "닫기", + contentDescription = stringResource(R.string.feature_badge_impl_close), ) } }, diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt index 4fe1919c..6e18a5f1 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt @@ -17,11 +17,13 @@ import androidx.compose.material3.ripple 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.core.ui.component.PrezelBadge +import com.team.prezel.feature.badge.impl.R import com.team.prezel.feature.badge.impl.model.BadgeUiModel import kotlinx.collections.immutable.ImmutableList @@ -35,12 +37,12 @@ internal fun BadgeListContent( ) { Column(modifier = modifier.fillMaxSize()) { PrezelTopAppBar( - title = { Text(text = "나의 뱃지") }, + title = { Text(text = stringResource(R.string.feature_badge_impl_title)) }, leadingIcon = { IconButton(onClick = onBack) { Icon( painter = painterResource(PrezelIcons.ChevronLeft), - contentDescription = "뒤로가기", + contentDescription = stringResource(R.string.feature_badge_impl_back), ) } }, diff --git a/Prezel/feature/badge/impl/src/main/res/values/strings.xml b/Prezel/feature/badge/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..ebe5b3ee --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + 나의 뱃지 + 뒤로가기 + 닫기 + + + 뱃지 목록을 불러오지 못했어요. + 뱃지 상세 정보를 불러오지 못했어요. + From eb70c2e05c03a1450559d795e1c7230c4c1dff2c Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 19:20:50 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20=EB=B1=83=EC=A7=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=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 * **refactor: `BadgeViewModel` 내 에러 메시지 노출 조건 강화** * 뱃지 상세 정보 조회 실패 시, 현재 선택된 뱃지(`selectedBadgeCode`)와 요청한 뱃지 코드가 일치하는 경우에만 에러 메시지를 표시하고 상세 뷰를 닫도록 로직을 개선했습니다. --- .../java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt index 284a8036..47371a87 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt @@ -81,8 +81,8 @@ internal class BadgeViewModel @Inject constructor( }.onFailure { if (currentState.selectedBadgeCode == badgeCode) { dismissBadgeDetail() + sendEffect(BadgeUiEffect.ShowMessage(BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED)) } - sendEffect(BadgeUiEffect.ShowMessage(BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED)) } } } From 88e3fd80550a86a37688215fad9258d1dc05970a Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 1 Jun 2026 19:28:29 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=EB=B1=83=EC=A7=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=A1=9C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20UI=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 * **feat: 뱃지 이미지 로드 에러 스낵바 알림 추가** * 이미지 로드 실패 시 표시할 에러 메시지(`badge_image_load_failed`)를 추가했습니다. * `BadgeScreen`에서 `rememberCoroutineScope`와 `LocalSnackbarHostState`를 활용하여 이미지 로드 실패 시 스낵바를 노출하는 로직을 구현했습니다. * **refactor: `BadgeDetailImage` 에러 처리 방식 개선** * 이미지 로드 실패 시 컴포넌트 내부에서 경고 아이콘을 보여주던 기존 방식 대신, `onError` 콜백을 통해 이벤트를 상위로 전파하도록 변경했습니다. * `LaunchedEffect`와 `hasReportedError` 상태를 도입하여 동일한 `imageUrl`에 대해 에러 콜백이 중복 호출되지 않도록 로직을 개선했습니다. * **refactor: `BadgeDetailModal` 및 하위 컴포넌트 리팩터링** * `BadgeDetailModal`과 `BadgeHeader`가 이미지 로드 실패 이벤트를 처리할 수 있도록 콜백을 추가했습니다. * `BadgeDetailChip`이 `BadgeDetailUiModel` 전체를 의존하는 대신, 필요한 정보인 `badgeCondition` 문자열만 직접 전달받도록 수정했습니다. * 뱃지 상세 칩에 표시되는 텍스트를 기존 `badgeName`에서 `conditionText`로 변경하여 정보를 명확히 했습니다. --- .../prezel/feature/badge/impl/BadgeScreen.kt | 12 +++++++++ .../badge/impl/component/BadgeDetailImage.kt | 27 ++++++++++--------- .../badge/impl/component/BadgeDetailModal.kt | 16 ++++++++--- .../impl/src/main/res/values/strings.xml | 1 + 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt index b54ca861..6a2a24db 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -22,6 +23,7 @@ import com.team.prezel.feature.badge.impl.contract.BadgeUiEffect import com.team.prezel.feature.badge.impl.contract.BadgeUiIntent import com.team.prezel.feature.badge.impl.contract.BadgeUiState import com.team.prezel.feature.badge.impl.model.BadgeUiMessage +import kotlinx.coroutines.launch @Composable internal fun BadgeScreen( @@ -31,8 +33,10 @@ internal fun BadgeScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() val fetchDataFailedMessage = stringResource(R.string.feature_badge_impl_message_fetch_badges_failed) val fetchBadgeDetailFailedMessage = stringResource(R.string.feature_badge_impl_message_fetch_badge_detail_failed) + val badgeImageLoadFailedMessage = stringResource(R.string.feature_badge_impl_message_badge_image_load_failed) LaunchedEffect(viewModel, fetchDataFailedMessage, fetchBadgeDetailFailedMessage) { viewModel.uiEffect.collect { effect -> @@ -53,6 +57,11 @@ internal fun BadgeScreen( onBack = onBack, onBadgeClick = { badgeCode -> viewModel.onIntent(BadgeUiIntent.ClickBadge(badgeCode = badgeCode)) }, onDismissBadgeDetail = { viewModel.onIntent(BadgeUiIntent.DismissBadgeDetail) }, + onBadgeImageLoadFailure = { + coroutineScope.launch { + snackbarHostState.showPrezelSnackbar(message = badgeImageLoadFailedMessage) + } + }, modifier = modifier, ) } @@ -63,6 +72,7 @@ internal fun BadgeScreenScreen( onBack: () -> Unit, onBadgeClick: (badgeCode: String) -> Unit, onDismissBadgeDetail: () -> Unit, + onBadgeImageLoadFailure: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -81,6 +91,7 @@ internal fun BadgeScreenScreen( badge = badge, badgeDetail = uiState.selectedBadgeDetail, onDismiss = onDismissBadgeDetail, + onImageLoadFailure = onBadgeImageLoadFailure, modifier = Modifier.fillMaxSize(), ) } @@ -96,6 +107,7 @@ private fun BadgeScreenPreview() { onBack = {}, onBadgeClick = {}, onDismissBadgeDetail = {}, + onBadgeImageLoadFailure = {}, ) } } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt index 4e790904..e135011e 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,7 +19,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.PrezelAsyncImage -import com.team.prezel.core.designsystem.component.image.PrezelImage import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -27,9 +27,14 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme internal fun BadgeDetailImage( imageUrl: String, isUnlocked: Boolean, + onError: () -> Unit, modifier: Modifier = Modifier, ) { - var isError by remember(imageUrl) { mutableStateOf(false) } + var hasReportedError by remember(imageUrl) { mutableStateOf(false) } + + LaunchedEffect(imageUrl) { + hasReportedError = false + } Box( modifier = modifier @@ -41,19 +46,14 @@ internal fun BadgeDetailImage( contentDescription = "", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - onError = { isError = true }, - onSuccess = { isError = false }, + onError = { + if (!hasReportedError) { + hasReportedError = true + onError() + } + }, ) - if (isError) { - PrezelImage( - resId = PrezelIcons.WarningCircleOutlined, - contentDescription = "", - modifier = Modifier.fillMaxSize(), - ) - return@Box - } - if (!isUnlocked) { Box( modifier = Modifier @@ -79,6 +79,7 @@ private fun BadgeDetailImagePreview() { BadgeDetailImage( imageUrl = "", isUnlocked = false, + onError = {}, ) } } diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt index 6657ac08..0eab9e17 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt @@ -38,6 +38,7 @@ internal fun BadgeDetailModal( badge: BadgeUiModel, badgeDetail: BadgeDetailUiModel?, onDismiss: () -> Unit, + onImageLoadFailure: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler(onBack = onDismiss) @@ -67,7 +68,11 @@ internal fun BadgeDetailModal( .weight(376f), horizontalAlignment = Alignment.CenterHorizontally, ) { - BadgeHeader(badge = badge, badgeDetail = badgeDetail) + BadgeHeader( + badge = badge, + badgeDetail = badgeDetail, + onImageLoadFailure = onImageLoadFailure, + ) badgeDetail?.let { detail -> Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) @@ -86,6 +91,7 @@ internal fun BadgeDetailModal( private fun BadgeHeader( badge: BadgeUiModel, badgeDetail: BadgeDetailUiModel?, + onImageLoadFailure: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(0.65f), @@ -95,6 +101,7 @@ private fun BadgeHeader( BadgeDetailImage( imageUrl = badge.imageUrl, isUnlocked = badge.isUnlocked, + onError = onImageLoadFailure, modifier = Modifier.fillMaxWidth(), ) @@ -110,18 +117,18 @@ private fun BadgeHeader( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) badgeDetail?.let { detail -> - BadgeDetailChip(detail = detail) + BadgeDetailChip(badgeCondition = detail.conditionText) } } } @Composable private fun BadgeDetailChip( - detail: BadgeDetailUiModel, + badgeCondition: String, modifier: Modifier = Modifier, ) { PrezelChip( - text = detail.badgeName, + text = badgeCondition, size = ChipSize.SMALL, hierarchy = ChipHierarchy.PRIMARY, state = ChipState.ACTIVE, @@ -151,6 +158,7 @@ private fun BadgeDetailModalPreview() { badge = badgePreviewBadges().first(), badgeDetail = badgePreviewDetail(), onDismiss = {}, + onImageLoadFailure = {}, modifier = Modifier.fillMaxSize(), ) } diff --git a/Prezel/feature/badge/impl/src/main/res/values/strings.xml b/Prezel/feature/badge/impl/src/main/res/values/strings.xml index ebe5b3ee..f6751596 100644 --- a/Prezel/feature/badge/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/badge/impl/src/main/res/values/strings.xml @@ -7,4 +7,5 @@ 뱃지 목록을 불러오지 못했어요. 뱃지 상세 정보를 불러오지 못했어요. + 뱃지 이미지를 불러오지 못했어요.