diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index ebd512a3..5ab843a7 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { implementation(projects.coreData) implementation(projects.coreDesignsystem) implementation(projects.coreDomain) + implementation(projects.coreModel) implementation(projects.coreNavigation) implementation(projects.coreUi) implementation(projects.coreCommon) @@ -87,6 +88,8 @@ dependencies { implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) implementation(projects.featureMyImpl) + implementation(projects.featureBadgeApi) + implementation(projects.featureBadgeImpl) implementation(projects.featureFeedbackApi) implementation(projects.featureFeedbackImpl) 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 70ce3f4e..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,11 +25,13 @@ 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 import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.core.view.WindowCompat @@ -37,12 +39,15 @@ 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.EdgeToEdgeStatusBarStyle 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 @@ -51,6 +56,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 @@ -59,6 +65,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) } @@ -75,6 +82,7 @@ fun PrezelApp( PrezelAppContent( appState = appState, globalEventBus = globalEventBus, + connectBadgeEventStreamUseCase = connectBadgeEventStreamUseCase, entryBuilders = entryBuilders, ) } @@ -84,6 +92,7 @@ fun PrezelApp( private fun PrezelAppContent( appState: PrezelAppState, globalEventBus: GlobalEventBus, + connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase, entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = LocalNavigator.current @@ -96,6 +105,13 @@ private fun PrezelAppContent( onStatusBarStyleChange = { statusBarStyle = it }, ) + ObserveBadgeEvents( + isAuthenticated = appState.isAuthenticated, + connectBadgeEventStreamUseCase = connectBadgeEventStreamUseCase, + shouldShowNavigationBar = appState.shouldShowNavigationBar, + navigateToBadge = { navigator.navigate(BadgeNavKey) }, + ) + Box(modifier = Modifier.fillMaxSize()) { SharedTransitionLayout { ProvideSharedTransitionScope(this@SharedTransitionLayout) { @@ -242,6 +258,43 @@ private tailrec fun Context.findActivity(): Activity? = else -> null } +@Composable +private fun ObserveBadgeEvents( + isAuthenticated: Boolean, + connectBadgeEventStreamUseCase: ConnectBadgeEventStreamUseCase, + shouldShowNavigationBar: Boolean, + navigateToBadge: () -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current + val resources = LocalResources.current + val currentShouldShowNavigationBar by rememberUpdatedState(shouldShowNavigationBar) + + LaunchedEffect(isAuthenticated) { + if (!isAuthenticated) return@LaunchedEffect + + connectBadgeEventStreamUseCase() + .collect { event -> + val message = + event.message + ?.takeIf(String::isNotBlank) + ?: event.badgeName + ?.takeIf(String::isNotBlank) + ?.let { badgeName -> + resources.getString(R.string.app_badge_event_message_with_name, badgeName) + } + ?: resources.getString(R.string.app_badge_event_message) + + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar( + message = message, + actionLabel = resources.getString(R.string.app_badge_event_action), + onAction = navigateToBadge, + useRaisedPosition = currentShouldShowNavigationBar, + ) + } + } +} + @Composable private fun PrezelNavigationScope.AppNavigationItems( appState: PrezelAppState, 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/app/src/main/res/values/strings.xml b/Prezel/app/src/main/res/values/strings.xml index dff017bb..4f91b4ba 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..512e8c5c --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt @@ -0,0 +1,66 @@ +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, + ) + +private fun GetBadgeDetailResponse.toDomain(): BadgeDetail = + BadgeDetail( + badgeCode = badgeCode, + badgeName = badgeName, + conditionText = conditionText, + detailDescription = detailDescription, + imageUrl = imageUrl, + isUnlocked = isUnlocked, + 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/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..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 @@ -1,15 +1,28 @@ 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 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..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 @@ -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 = 10 + reconnectionTime = 5.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/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..a10e2621 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt @@ -0,0 +1,79 @@ +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.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 + +internal class BadgeRemoteDataSourceImpl @Inject constructor( + private val badgeService: BadgeService, + private val httpClient: HttpClient, +) : BadgeRemoteDataSource { + 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 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 + private val badgeStreamUrl = "${BuildConfig.BASE_URL.trimEnd('/')}/api/stream/badges" + } +} 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/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..2fda7f66 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/badge/BadgeEventResponse.kt @@ -0,0 +1,20 @@ +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("introduction") + val introduction: String? = null, + @SerialName("imageUrl") + val imageUrl: String? = null, + @SerialName("message") + val message: String? = null, + @SerialName("rawData") + 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..befe6f8f --- /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..50977467 --- /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..6a2a24db --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeScreen.kt @@ -0,0 +1,113 @@ +package com.team.prezel.feature.badge.impl + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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 +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 +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( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: BadgeViewModel = hiltViewModel(), +) { + 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 -> + when (effect) { + is BadgeUiEffect.ShowMessage -> { + val message = when (effect.message) { + BadgeUiMessage.FETCH_DATA_FAILED -> fetchDataFailedMessage + BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED -> fetchBadgeDetailFailedMessage + } + snackbarHostState.showPrezelSnackbar(message = message) + } + } + } + } + + BadgeScreenScreen( + uiState = uiState, + onBack = onBack, + onBadgeClick = { badgeCode -> viewModel.onIntent(BadgeUiIntent.ClickBadge(badgeCode = badgeCode)) }, + onDismissBadgeDetail = { viewModel.onIntent(BadgeUiIntent.DismissBadgeDetail) }, + onBadgeImageLoadFailure = { + coroutineScope.launch { + snackbarHostState.showPrezelSnackbar(message = badgeImageLoadFailedMessage) + } + }, + modifier = modifier, + ) +} + +@Composable +internal fun BadgeScreenScreen( + uiState: BadgeUiState, + onBack: () -> Unit, + onBadgeClick: (badgeCode: String) -> Unit, + onDismissBadgeDetail: () -> Unit, + onBadgeImageLoadFailure: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + BadgeListContent( + badges = uiState.badges, + onBack = onBack, + onBadgeClick = onBadgeClick, + ) + + uiState.selectedBadge?.let { badge -> + BadgeDetailModal( + badge = badge, + badgeDetail = uiState.selectedBadgeDetail, + onDismiss = onDismissBadgeDetail, + onImageLoadFailure = onBadgeImageLoadFailure, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@BasicPreview +@Composable +private fun BadgeScreenPreview() { + PrezelTheme { + BadgeScreenScreen( + uiState = badgeScreenPreviewState(), + onBack = {}, + onBadgeClick = {}, + onDismissBadgeDetail = {}, + onBadgeImageLoadFailure = {}, + ) + } +} 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..47371a87 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/BadgeViewModel.kt @@ -0,0 +1,116 @@ +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.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.launch +import javax.inject.Inject + +@HiltViewModel +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 -> fetchBadgeDetail(intent.badgeCode) + BadgeUiIntent.DismissBadgeDetail -> dismissBadgeDetail() + } + } + + private fun fetchData() { + viewModelScope.launch { + updateState { copy(isLoading = true) } + fetchBadgesUseCase() + .onSuccess { badges -> handleFetchDataSuccess(badges = badges) } + .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 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, + ) + } + + 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) } + } + }.onFailure { + if (currentState.selectedBadgeCode == badgeCode) { + dismissBadgeDetail() + sendEffect(BadgeUiEffect.ShowMessage(BadgeUiMessage.FETCH_BADGE_DETAIL_FAILED)) + } + } + } + } + + private fun dismissBadgeDetail() { + updateState { + copy( + selectedBadgeCode = null, + selectedBadgeDetail = null, + ) + } + } + + 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..e135011e --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailImage.kt @@ -0,0 +1,85 @@ +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.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.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, + onError: () -> Unit, + modifier: Modifier = Modifier, +) { + var hasReportedError by remember(imageUrl) { mutableStateOf(false) } + + LaunchedEffect(imageUrl) { + hasReportedError = false + } + + Box( + modifier = modifier + .aspectRatio(1f) + .clip(PrezelTheme.shapes.V16), + ) { + PrezelAsyncImage( + url = imageUrl, + contentDescription = "", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + onError = { + if (!hasReportedError) { + hasReportedError = true + onError() + } + }, + ) + + 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, + onError = {}, + ) + } +} 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..0eab9e17 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt @@ -0,0 +1,165 @@ +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.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.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 +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 +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BadgeDetailModal( + badge: BadgeUiModel, + badgeDetail: BadgeDetailUiModel?, + onDismiss: () -> Unit, + onImageLoadFailure: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onDismiss) + + Column( + modifier = modifier + .background(PrezelTheme.colors.bgRegular) + .noRippleClickable { /* 클릭 이벤트 소비를 위함 */ }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PrezelTopAppBar( + trailingIcons = { + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = stringResource(R.string.feature_badge_impl_close), + ) + } + }, + ) + + Spacer(modifier = Modifier.weight(72f)) + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(376f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BadgeHeader( + badge = badge, + badgeDetail = badgeDetail, + onImageLoadFailure = onImageLoadFailure, + ) + + badgeDetail?.let { detail -> + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + BadgeDetailDescription( + detail = detail, + modifier = Modifier.padding(horizontal = PrezelTheme.spacing.V24), + ) + } + } + + Spacer(modifier = Modifier.weight(180f)) + } +} + +@Composable +private fun BadgeHeader( + badge: BadgeUiModel, + badgeDetail: BadgeDetailUiModel?, + onImageLoadFailure: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(0.65f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BadgeDetailImage( + imageUrl = badge.imageUrl, + isUnlocked = badge.isUnlocked, + onError = onImageLoadFailure, + 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(badgeCondition = detail.conditionText) + } + } +} + +@Composable +private fun BadgeDetailChip( + badgeCondition: String, + modifier: Modifier = Modifier, +) { + PrezelChip( + text = badgeCondition, + 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() { + PrezelTheme { + BadgeDetailModal( + badge = badgePreviewBadges().first(), + badgeDetail = badgePreviewDetail(), + onDismiss = {}, + onImageLoadFailure = {}, + 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..6e18a5f1 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeListContent.kt @@ -0,0 +1,85 @@ +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 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 + +@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 = stringResource(R.string.feature_badge_impl_title)) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ChevronLeft), + contentDescription = stringResource(R.string.feature_badge_impl_back), + ) + } + }, + ) + + 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/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..f18b5de9 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiIntent.kt @@ -0,0 +1,11 @@ +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 + + 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 new file mode 100644 index 00000000..99627116 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/contract/BadgeUiState.kt @@ -0,0 +1,18 @@ +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 + +@Immutable +internal data class BadgeUiState( + val isLoading: Boolean = false, + val badges: ImmutableList = persistentListOf(), + val selectedBadgeCode: String? = null, + val selectedBadgeDetail: BadgeDetailUiModel? = null, +) : 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 new file mode 100644 index 00000000..bd476b45 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiMessage.kt @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..16563b27 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/model/BadgeUiModel.kt @@ -0,0 +1,11 @@ +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 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/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..f6751596 --- /dev/null +++ b/Prezel/feature/badge/impl/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + 나의 뱃지 + 뒤로가기 + 닫기 + + + 뱃지 목록을 불러오지 못했어요. + 뱃지 상세 정보를 불러오지 못했어요. + 뱃지 이미지를 불러오지 못했어요. + 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, 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..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 @@ -16,7 +17,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 @@ -76,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) @@ -106,15 +106,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/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 diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index f7b56c39..bf13a5ec 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:feedback:api",