diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 48bc342a..93cbbaf1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -17,12 +17,21 @@
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:label="@string/app_name"
+ android:launchMode="singleTask"
android:theme="@style/Theme.Todakun">
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/kikidan/todakun/MainActivity.kt b/app/src/main/java/com/kikidan/todakun/MainActivity.kt
index 574aa8cb..6cd06fdf 100644
--- a/app/src/main/java/com/kikidan/todakun/MainActivity.kt
+++ b/app/src/main/java/com/kikidan/todakun/MainActivity.kt
@@ -1,17 +1,14 @@
package com.kikidan.todakun
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.domain.model.notification.PushNotificationEvent
+import com.kikidan.navigation.TodakunRoute
+import com.kikidan.navigation.parseDeepLink
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -21,32 +18,14 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
TodakunTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding),
- )
- }
+ TodakunApp(deepLinkRoute = handleDeepLinkIntent(intent))
}
}
}
-}
-
-@Composable
-fun Greeting(
- name: String,
- modifier: Modifier = Modifier,
-) {
- Text(
- text = "Hello $name!",
- modifier = modifier,
- )
-}
-@Preview(showBackground = true)
-@Composable
-private fun GreetingPreview() {
- TodakunTheme {
- Greeting("Android")
+ /** FCM 백그라운드 알림의 PendingIntent extra를 우선 확인하고, 없으면 외부 공유 링크(intent.data)를 확인한다. */
+ private fun handleDeepLinkIntent(intent: Intent?): TodakunRoute? {
+ val deepLink = intent?.extras?.getString(PushNotificationEvent.DEEP_LINK_KEY)
+ return deepLink?.let { parseDeepLink(it) }
}
}
diff --git a/app/src/main/java/com/kikidan/todakun/TodakunApp.kt b/app/src/main/java/com/kikidan/todakun/TodakunApp.kt
new file mode 100644
index 00000000..1b56d451
--- /dev/null
+++ b/app/src/main/java/com/kikidan/todakun/TodakunApp.kt
@@ -0,0 +1,348 @@
+package com.kikidan.todakun
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+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.Modifier
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import com.kikidan.auth.LoginRoute
+import com.kikidan.chat.ChatRoute
+import com.kikidan.chat.HistoryRoute
+import com.kikidan.designsystem.component.bottomnavigation.TodakunBottomNavigation
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.domain.model.auth.OnboardingToken
+import com.kikidan.domain.model.notification.PushNotificationEvent
+import com.kikidan.domain.notification.PushNotificationEventFlow
+import com.kikidan.home.FortuneReportRoute
+import com.kikidan.home.HomeRoute
+import com.kikidan.luckaction.LuckActionRoute
+import com.kikidan.mypage.edit.ui.MyPageEditRoute
+import com.kikidan.mypage.home.ui.MyPageHomeRoute
+import com.kikidan.mypage.mansaeryeok.ui.MansaeryeokDetailRoute
+import com.kikidan.mypage.notification.ui.NotificationSettingRoute
+import com.kikidan.mypage.partner.form.ui.PartnerSajuFormRoute
+import com.kikidan.mypage.partner.ui.PartnerSajuManagementRoute
+import com.kikidan.mypage.setting.ui.AppSettingRoute
+import com.kikidan.mypage.setting.ui.AppSettingWithdrawalNoticeRoute
+import com.kikidan.mypage.setting.ui.AppSettingWithdrawalRoute
+import com.kikidan.navigation.TodakunNavigator
+import com.kikidan.navigation.TodakunRoute
+import com.kikidan.notification.NotificationRoute
+import com.kikidan.notification.component.PushNotificationBanner
+import com.kikidan.onboarding.OnboardingRoute
+import com.kikidan.onboarding.TermsRoute
+import com.kikidan.sajucontents.CompatibilityEntryRoute
+import com.kikidan.sajucontents.CompatibilityResultRoute
+import com.kikidan.sajucontents.DateFortuneInputRoute
+import com.kikidan.sajucontents.DateFortuneResultRoute
+import com.kikidan.sajucontents.YearFortuneResultRoute
+import com.kikidan.sajucontents.YearSelectionRoute
+
+@Composable
+fun TodakunApp(
+ deepLinkRoute: TodakunRoute?,
+ modifier: Modifier = Modifier,
+) {
+ val backStack = rememberNavBackStack(TodakunRoute.Login)
+ val navigator = remember(backStack) { TodakunNavigator(backStack) }
+ val snackbarHostState = remember { SnackbarHostState() }
+ var inAppEvent by remember { mutableStateOf(null) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ LaunchedEffect(lifecycleOwner) {
+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ PushNotificationEventFlow.events.collect { event -> inAppEvent = event }
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ containerColor = TodakunColor.white,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ bottomBar = {
+ navigator.currentTab?.let { item ->
+ TodakunBottomNavigation(
+ modifier = Modifier.navigationBarsPadding(),
+ selectedItem = item,
+ onItemSelect = navigator::selectTab,
+ )
+ }
+ },
+ contentWindowInsets = WindowInsets(),
+ ) { innerPadding ->
+ NavDisplay(
+ modifier = Modifier.padding(innerPadding),
+ backStack = backStack,
+ entryDecorators =
+ listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator(),
+ ),
+ popTransitionSpec = {
+ EnterTransition.None togetherWith ExitTransition.None
+ },
+ predictivePopTransitionSpec = {
+ EnterTransition.None togetherWith ExitTransition.None
+ },
+ entryProvider =
+ entryProvider {
+ entry {
+ LoginRoute(
+ onAuthSuccess = { result ->
+ if (result.newMember) {
+ navigator.resetTo(
+ TodakunRoute.Terms(
+ result.onboardingToken?.value.orEmpty(),
+ ),
+ )
+ } else {
+ navigator.resetTo(TodakunRoute.Home)
+ if (deepLinkRoute != null) {
+ navigator.push(deepLinkRoute)
+ }
+ }
+ },
+ onAuthPass = {
+ navigator.resetTo(TodakunRoute.Home)
+ if (deepLinkRoute != null) {
+ navigator.push(deepLinkRoute)
+ }
+ },
+ )
+ }
+
+ entry { route ->
+ TermsRoute(
+ onNavigateOnboarding = {
+ navigator.push(TodakunRoute.Onboarding(route.onboardingToken))
+ },
+ onExit = { navigator.resetTo(TodakunRoute.Login) },
+ )
+ }
+
+ entry { route ->
+ OnboardingRoute(
+ onFinish = { navigator.resetTo(TodakunRoute.Home) },
+ onNavigateTerm = { navigator.goBack() },
+ onboardingToken = OnboardingToken(route.onboardingToken),
+ )
+ }
+
+ entry {
+ HomeRoute(
+ snackbarHostState = snackbarHostState,
+ onNavigateToLuckAction = { navigator.resetTo(TodakunRoute.LuckAction) },
+ onNavigateToReport = { fortuneId ->
+ navigator.push(TodakunRoute.FortuneReport(fortuneId))
+ },
+ onNavigateToNotice = { navigator.push(TodakunRoute.Notification) },
+ onNavigateToCompatibility = { navigator.push(TodakunRoute.CompatibilityInput) },
+ onNavigateToDateFortune = { navigator.push(TodakunRoute.DateFortuneInput) },
+ onNavigateToYearFortune = { navigator.push(TodakunRoute.YearFortuneInput) },
+ )
+ }
+
+ entry { route ->
+ ChatRoute(
+ conversationId = route.conversationId,
+ skipSplash = route.skipSplash,
+ onCloseClick = { navigator.goBack() },
+ onNavigateToHistory = { navigator.push(TodakunRoute.ChatHistory) },
+ snackbarHostState = snackbarHostState,
+ )
+ }
+
+ entry {
+ HistoryRoute(
+ onBackClick = { navigator.goBack() },
+ onNavigateToChat = { conversationId ->
+ navigator.push(TodakunRoute.Chat(conversationId))
+ },
+ onNewChatClick = { navigator.push(TodakunRoute.Chat(skipSplash = true)) },
+ snackbarHostState = snackbarHostState,
+ )
+ }
+
+ entry {
+ LuckActionRoute(snackbarHostState = snackbarHostState)
+ }
+
+ entry {
+ MyPageHomeRoute(
+ modifier = Modifier.systemBarsPadding(),
+ onNavigateToEdit = { navigator.push(TodakunRoute.MyPageEdit) },
+ onNavigateToMansaeryeok = { navigator.push(TodakunRoute.Mansaeryeok) },
+ onNavigateToPartnerSajuManagement = {
+ navigator.push(TodakunRoute.PartnerSajuManagement)
+ },
+ onNavigateToNotificationSetting = {
+ navigator.push(TodakunRoute.NotificationSetting)
+ },
+ onNavigateToAppSetting = { navigator.push(TodakunRoute.AppSetting) },
+ )
+ }
+
+ entry {
+ MyPageEditRoute(
+ onNavigateBack = { navigator.goBack() },
+ )
+ }
+
+ entry {
+ MansaeryeokDetailRoute(onNavigateBack = { navigator.goBack() })
+ }
+
+ entry {
+ NotificationSettingRoute(onNavigateBack = { navigator.goBack() })
+ }
+
+ entry {
+ PartnerSajuManagementRoute(
+ onNavigateBack = { navigator.goBack() },
+ onAddPartnerClick = { navigator.push(TodakunRoute.PartnerSajuForm()) },
+ onEditPartnerClick = { linkId ->
+ navigator.push(TodakunRoute.PartnerSajuForm(linkId))
+ },
+ )
+ }
+
+ entry { route ->
+ PartnerSajuFormRoute(
+ onNavigateBack = { navigator.goBack() },
+ partnerLinkId = route.partnerLinkId,
+ )
+ }
+
+ entry {
+ AppSettingRoute(
+ onNavigateBack = { navigator.goBack() },
+ onWithdrawalClick = { navigator.push(TodakunRoute.AppSettingWithdrawal) },
+ )
+ }
+
+ entry {
+ AppSettingWithdrawalRoute(
+ onNavigateBack = { navigator.goBack() },
+ onNextClick = { reason, detailReason ->
+ navigator.push(TodakunRoute.AppSettingWithdrawalNotice(reason, detailReason))
+ },
+ )
+ }
+
+ entry { route ->
+ AppSettingWithdrawalNoticeRoute(
+ onNavigateBack = { navigator.goBack() },
+ reason = route.reason,
+ detailReason = route.detailReason,
+ onWithdrawalSuccess = { navigator.resetTo(TodakunRoute.Login) },
+ )
+ }
+
+ entry {
+ CompatibilityEntryRoute(
+ snackbarHostState = snackbarHostState,
+ onNavigateBack = { navigator.goBack() },
+ onNavigateMyPage = { navigator.push(TodakunRoute.MyPageEdit) },
+ onNavigateToPartnerForm = { navigator.push(TodakunRoute.PartnerSajuForm()) },
+ onNavigateToResult = { compatibilityId, partnerLinkId ->
+ navigator.push(TodakunRoute.CompatibilityResult(compatibilityId, partnerLinkId))
+ },
+ )
+ }
+
+ entry { route ->
+ CompatibilityResultRoute(
+ compatibilityId = route.compatibilityId,
+ partnerLinkId = route.partnerLinkId,
+ snackbarHostState = snackbarHostState,
+ onNavigateBack = { navigator.goBack() },
+ onAskTodakClick = { navigator.push(TodakunRoute.Chat()) },
+ )
+ }
+
+ entry {
+ DateFortuneInputRoute(
+ snackbarHostState = snackbarHostState,
+ onNavigateBack = { navigator.goBack() },
+ onNavigateToResult = { ids -> navigator.push(TodakunRoute.DateFortuneResult(ids)) },
+ )
+ }
+
+ entry { route ->
+ DateFortuneResultRoute(
+ ids = route.ids,
+ snackbarHostState = snackbarHostState,
+ onNavigateBack = { navigator.goBack() },
+ onAskTodakClick = { navigator.push(TodakunRoute.Chat()) },
+ )
+ }
+
+ entry {
+ YearSelectionRoute(
+ snackbarHostState = snackbarHostState,
+ onNavigateBack = { navigator.goBack() },
+ onNavigateToResult = { id -> navigator.push(TodakunRoute.YearFortuneResult(id)) },
+ )
+ }
+
+ entry { route ->
+ YearFortuneResultRoute(
+ id = route.id,
+ snackbarHostState = snackbarHostState,
+ onNavigateBack = { navigator.goBack() },
+ onAskTodakClick = { navigator.push(TodakunRoute.Chat()) },
+ )
+ }
+
+ entry { route ->
+ FortuneReportRoute(
+ fortuneId = route.fortuneId,
+ snackbarHostState = snackbarHostState,
+ onNavigateToBack = { navigator.goBack() },
+ onNavigateToLuckAction = { navigator.resetTo(TodakunRoute.LuckAction) },
+ onNavigateToChat = { navigator.push(TodakunRoute.Chat()) },
+ )
+ }
+
+ entry {
+ NotificationRoute(
+ onBackClick = { navigator.goBack() },
+ onNavigateToDeepLink = navigator::navigateOrFallbackToDeepLink,
+ snackbarHostState = snackbarHostState,
+ )
+ }
+ },
+ )
+ if (inAppEvent != null) {
+ PushNotificationBanner(
+ modifier = Modifier.systemBarsPadding(),
+ event = inAppEvent,
+ onDismiss = { inAppEvent = null },
+ onClick = { event ->
+ inAppEvent = null
+ event.deepLink?.let(navigator::navigateOrFallbackToDeepLink)
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/app_icon.xml b/app/src/main/res/drawable/app_icon.xml
new file mode 100644
index 00000000..ef4224f5
--- /dev/null
+++ b/app/src/main/res/drawable/app_icon.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
index b3e26b4c..3af5e3fb 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -1,6 +1,6 @@
-
-
-
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 234668ef..de8f7a8f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,3 @@
- todakun
+ 토닥운 - 사주 운세
\ No newline at end of file
diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/bottomnavigation/TodakunBottomNavigation.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/bottomnavigation/TodakunBottomNavigation.kt
index 0268804b..20e23718 100644
--- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/bottomnavigation/TodakunBottomNavigation.kt
+++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/bottomnavigation/TodakunBottomNavigation.kt
@@ -19,7 +19,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.dropShadow
+import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -41,7 +43,15 @@ fun TodakunBottomNavigation(
modifier
.fillMaxWidth()
.height(TodakunBottomNavigationDefaults.ContainerHeight)
- .dropShadow(
+ // 하단만 그림자 클립
+ .drawWithContent {
+ clipRect(
+ left = -size.width,
+ top = -size.height,
+ right = size.width * 2,
+ bottom = size.height,
+ ) { this@drawWithContent.drawContent() }
+ }.dropShadow(
shape = TodakunBottomNavigationDefaults.ContainerShape,
shadow =
Shadow(
diff --git a/core/domain/src/main/java/com/kikidan/domain/model/notification/PushNotificationEvent.kt b/core/domain/src/main/java/com/kikidan/domain/model/notification/PushNotificationEvent.kt
index 0b29594c..d8e23d85 100644
--- a/core/domain/src/main/java/com/kikidan/domain/model/notification/PushNotificationEvent.kt
+++ b/core/domain/src/main/java/com/kikidan/domain/model/notification/PushNotificationEvent.kt
@@ -8,6 +8,6 @@ data class PushNotificationEvent(
val deepLink: String?,
) {
companion object {
- const val DEEP_LINK_KEY = "deeplink"
+ const val DEEP_LINK_KEY = "deepLink"
}
}
diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts
index c74138c9..4d98c4d1 100644
--- a/core/navigation/build.gradle.kts
+++ b/core/navigation/build.gradle.kts
@@ -20,6 +20,10 @@ android {
}
dependencies {
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(projects.core.designsystem)
+ api(libs.androidx.navigation3.runtime)
+
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
diff --git a/core/navigation/src/main/java/com/kikidan/navigation/DeepLinkParser.kt b/core/navigation/src/main/java/com/kikidan/navigation/DeepLinkParser.kt
new file mode 100644
index 00000000..8b521de2
--- /dev/null
+++ b/core/navigation/src/main/java/com/kikidan/navigation/DeepLinkParser.kt
@@ -0,0 +1,45 @@
+package com.kikidan.navigation
+
+/**
+ * `todakun://` 커스텀 스킴 딥링크 문자열을 [TodakunRoute]로 변환한다.
+ * 매칭되는 패턴이 없으면 null을 반환하며, 폴백 정책은 호출부(Root)가 결정한다.
+ */
+fun parseDeepLink(uri: String): TodakunRoute? {
+ val path = uri.removePrefix(SCHEME_PREFIX)
+ if (path == uri) return null
+
+ return when {
+ path == PATH_LUCKY_ACTION -> {
+ TodakunRoute.LuckAction
+ }
+
+ path.startsWith(PATH_FORTUNE_PREFIX) -> {
+ path
+ .removePrefix(PATH_FORTUNE_PREFIX)
+ .takeIf { it.isNotBlank() }
+ ?.let { fortuneId -> TodakunRoute.FortuneReport(fortuneId = fortuneId) }
+ }
+
+ path.startsWith(PATH_CHAT_CONVERSATION_PREFIX) -> {
+ path
+ .removePrefix(PATH_CHAT_CONVERSATION_PREFIX)
+ .takeIf { it.isNotBlank() }
+ ?.let { conversationId -> TodakunRoute.Chat(conversationId = conversationId) }
+ }
+
+ path.startsWith(PATH_NOTICE_PREFIX) -> {
+ TodakunRoute.Notification
+ }
+
+ else -> {
+ null
+ }
+ }
+}
+
+private const val SCHEME_PREFIX = "todakun://"
+private const val PATH_LUCKY_ACTION = "lucky-action"
+private const val PATH_FORTUNE_PREFIX = "fortune/"
+private const val PATH_CHAT_CONVERSATION_PREFIX = "chat/conversations/"
+
+private const val PATH_NOTICE_PREFIX = "notice/"
diff --git a/core/navigation/src/main/java/com/kikidan/navigation/TodakunNavigator.kt b/core/navigation/src/main/java/com/kikidan/navigation/TodakunNavigator.kt
new file mode 100644
index 00000000..044451c6
--- /dev/null
+++ b/core/navigation/src/main/java/com/kikidan/navigation/TodakunNavigator.kt
@@ -0,0 +1,51 @@
+package com.kikidan.navigation
+
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import com.kikidan.designsystem.component.bottomnavigation.TodakunNavItem
+
+class TodakunNavigator(
+ val backStack: NavBackStack,
+) {
+ val currentTab: TodakunNavItem?
+ get() = (backStack.lastOrNull() as? TodakunRoute)?.toNavItemOrNull()
+
+ fun resetTo(route: TodakunRoute) {
+ backStack.clear()
+ backStack.add(route)
+ }
+
+ fun push(route: TodakunRoute) {
+ backStack.add(route)
+ }
+
+ fun goBack() {
+ if (backStack.size > 1) backStack.removeLastOrNull()
+ }
+
+ fun selectTab(item: TodakunNavItem) {
+ val route = item.toRoute()
+ resetTo(TodakunRoute.Home)
+ push(route)
+ }
+
+ fun navigateOrFallbackToDeepLink(deepLink: String) {
+ parseDeepLink(deepLink)?.let(::push) ?: push(TodakunRoute.Notification)
+ }
+}
+
+private fun TodakunRoute.toNavItemOrNull(): TodakunNavItem? =
+ when (this) {
+ TodakunRoute.Home -> TodakunNavItem.FORTUNE_TELLING
+ TodakunRoute.LuckAction -> TodakunNavItem.LUCKY_ACTION
+ TodakunRoute.MyPage -> TodakunNavItem.MY
+ else -> null
+ }
+
+private fun TodakunNavItem.toRoute(): TodakunRoute =
+ when (this) {
+ TodakunNavItem.FORTUNE_TELLING -> TodakunRoute.Home
+ TodakunNavItem.TODAK_CHAT -> TodakunRoute.Chat()
+ TodakunNavItem.LUCKY_ACTION -> TodakunRoute.LuckAction
+ TodakunNavItem.MY -> TodakunRoute.MyPage
+ }
diff --git a/core/navigation/src/main/java/com/kikidan/navigation/TodakunRoute.kt b/core/navigation/src/main/java/com/kikidan/navigation/TodakunRoute.kt
new file mode 100644
index 00000000..e4a64588
--- /dev/null
+++ b/core/navigation/src/main/java/com/kikidan/navigation/TodakunRoute.kt
@@ -0,0 +1,100 @@
+package com.kikidan.navigation
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+sealed interface TodakunRoute : NavKey {
+ @Serializable
+ data object Login : TodakunRoute
+
+ @Serializable
+ data class Onboarding(
+ val onboardingToken: String,
+ ) : TodakunRoute
+
+ @Serializable
+ data class Terms(
+ val onboardingToken: String,
+ ) : TodakunRoute
+
+ @Serializable
+ data object Home : TodakunRoute
+
+ @Serializable
+ data class Chat(
+ val conversationId: String? = null,
+ // 히스토리 화면의 "새 채팅"처럼 채팅 기능 안에서 이동할 때는 콜드 스타트 스플래시를 보여줄 필요가 없다.
+ val skipSplash: Boolean = false,
+ ) : TodakunRoute
+
+ @Serializable
+ data object ChatHistory : TodakunRoute
+
+ @Serializable
+ data object LuckAction : TodakunRoute
+
+ @Serializable
+ data object MyPage : TodakunRoute
+
+ @Serializable
+ data object MyPageEdit : TodakunRoute
+
+ @Serializable
+ data object Mansaeryeok : TodakunRoute
+
+ @Serializable
+ data object NotificationSetting : TodakunRoute
+
+ @Serializable
+ data object PartnerSajuManagement : TodakunRoute
+
+ @Serializable
+ data class PartnerSajuForm(
+ val partnerLinkId: String? = null,
+ ) : TodakunRoute
+
+ @Serializable
+ data object AppSetting : TodakunRoute
+
+ @Serializable
+ data object AppSettingWithdrawal : TodakunRoute
+
+ @Serializable
+ data class AppSettingWithdrawalNotice(
+ val reason: String,
+ val detailReason: String,
+ ) : TodakunRoute
+
+ @Serializable
+ data object CompatibilityInput : TodakunRoute
+
+ @Serializable
+ data class CompatibilityResult(
+ val compatibilityId: String,
+ val partnerLinkId: String? = null,
+ ) : TodakunRoute
+
+ @Serializable
+ data object DateFortuneInput : TodakunRoute
+
+ @Serializable
+ data class DateFortuneResult(
+ val ids: List,
+ ) : TodakunRoute
+
+ @Serializable
+ data object YearFortuneInput : TodakunRoute
+
+ @Serializable
+ data class YearFortuneResult(
+ val id: String,
+ ) : TodakunRoute
+
+ @Serializable
+ data class FortuneReport(
+ val fortuneId: String,
+ ) : TodakunRoute
+
+ @Serializable
+ data object Notification : TodakunRoute
+}
diff --git a/core/navigation/src/test/java/com/kikidan/navigation/DeepLinkParserTest.kt b/core/navigation/src/test/java/com/kikidan/navigation/DeepLinkParserTest.kt
new file mode 100644
index 00000000..977ae21c
--- /dev/null
+++ b/core/navigation/src/test/java/com/kikidan/navigation/DeepLinkParserTest.kt
@@ -0,0 +1,35 @@
+package com.kikidan.navigation
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class DeepLinkParserTest {
+ @Test
+ fun `FORTUNE 딥링크는 FortuneReport로 매핑된다`() {
+ val route = parseDeepLink("todakun://fortune/today")
+
+ assertEquals(TodakunRoute.FortuneReport(fortuneId = "today"), route)
+ }
+
+ @Test
+ fun `LUCKY_ACTION 딥링크는 LuckAction으로 매핑된다`() {
+ val route = parseDeepLink("todakun://lucky-action")
+
+ assertEquals(TodakunRoute.LuckAction, route)
+ }
+
+ @Test
+ fun `AI_COMPLETE 딥링크는 conversationId를 포함한 Chat으로 매핑된다`() {
+ val route = parseDeepLink("todakun://chat/conversations/conv-123")
+
+ assertEquals(TodakunRoute.Chat(conversationId = "conv-123"), route)
+ }
+
+ @Test
+ fun `todakun 스킴이 아니면 null을 반환한다`() {
+ val route = parseDeepLink("https://example.com/fortune/today")
+
+ assertNull(route)
+ }
+}
diff --git a/core/navigation/src/test/java/com/kikidan/navigation/NavigatorTest.kt b/core/navigation/src/test/java/com/kikidan/navigation/NavigatorTest.kt
new file mode 100644
index 00000000..0bcc7ac3
--- /dev/null
+++ b/core/navigation/src/test/java/com/kikidan/navigation/NavigatorTest.kt
@@ -0,0 +1,76 @@
+package com.kikidan.navigation
+
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import com.kikidan.designsystem.component.bottomnavigation.TodakunNavItem
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NavigatorTest {
+ private fun navigator(start: TodakunRoute = TodakunRoute.Login) = TodakunNavigator(NavBackStack(start))
+
+ @Test
+ fun `파싱 실패한 딥링크는 알림함으로 폴백한다`() {
+ val navigator = navigator(start = TodakunRoute.Home)
+
+ navigator.navigateOrFallbackToDeepLink("todakun://unknown")
+
+ assertEquals(listOf(TodakunRoute.Home, TodakunRoute.Notification), navigator.backStack.toList())
+ }
+
+ @Test
+ fun `goBack은 루트가 하나 남으면 아무 동작도 하지 않는다`() {
+ val navigator = navigator(start = TodakunRoute.Home)
+
+ navigator.goBack()
+
+ assertEquals(listOf(TodakunRoute.Home), navigator.backStack.toList())
+ }
+
+ @Test
+ fun `goBack은 최상단 라우트를 제거한다`() {
+ val navigator = navigator(start = TodakunRoute.Home)
+ navigator.push(TodakunRoute.LuckAction)
+
+ navigator.goBack()
+
+ assertEquals(listOf(TodakunRoute.Home), navigator.backStack.toList())
+ }
+
+ @Test
+ fun `LuckAction MyPage 탭 선택 시 Home 위에 push된다`() {
+ val navigator = navigator(start = TodakunRoute.Home)
+
+ navigator.selectTab(TodakunNavItem.LUCKY_ACTION)
+ assertEquals(listOf(TodakunRoute.Home, TodakunRoute.LuckAction), navigator.backStack.toList())
+ assertEquals(TodakunNavItem.LUCKY_ACTION, navigator.currentTab)
+
+ navigator.selectTab(TodakunNavItem.MY)
+ assertEquals(listOf(TodakunRoute.Home, TodakunRoute.MyPage), navigator.backStack.toList())
+ assertEquals(TodakunNavItem.MY, navigator.currentTab)
+ }
+
+ @Test
+ fun `Chat 탭 선택 시 Home 위에 push되고 currentTab은 null이라 바텀 내비게이션이 숨겨진다`() {
+ val navigator = navigator(start = TodakunRoute.Home)
+
+ navigator.selectTab(TodakunNavItem.TODAK_CHAT)
+
+ assertEquals(listOf(TodakunRoute.Home, TodakunRoute.Chat()), navigator.backStack.toList())
+ assertNull(navigator.currentTab)
+ }
+
+ @Test
+ fun `Chat에서 goBack하면 Home으로 돌아와 바텀 내비게이션이 다시 보인다`() {
+ val navigator = navigator(start = TodakunRoute.Home)
+ navigator.selectTab(TodakunNavItem.TODAK_CHAT)
+
+ navigator.goBack()
+
+ assertEquals(listOf(TodakunRoute.Home), navigator.backStack.toList())
+ assertEquals(TodakunNavItem.FORTUNE_TELLING, navigator.currentTab)
+ }
+}
diff --git a/feature/auth/src/main/java/com/kikidan/auth/LoginViewModel.kt b/feature/auth/src/main/java/com/kikidan/auth/LoginViewModel.kt
index 85dc3172..3a80d4a0 100644
--- a/feature/auth/src/main/java/com/kikidan/auth/LoginViewModel.kt
+++ b/feature/auth/src/main/java/com/kikidan/auth/LoginViewModel.kt
@@ -46,7 +46,9 @@ class LoginViewModel
loginUseCase(credential)
.onSuccess { result ->
- registerMessagingToken()
+ if (!result.newMember) {
+ registerMessagingToken()
+ }
reduce { LoginState.Success }
postSideEffect(LoginSideEffect.LoginSucceeded(result))
}.onFailure { error ->
diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatRoute.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatRoute.kt
index c1ce47e9..93c45c5d 100644
--- a/feature/chat/src/main/java/com/kikidan/chat/ChatRoute.kt
+++ b/feature/chat/src/main/java/com/kikidan/chat/ChatRoute.kt
@@ -23,6 +23,7 @@ fun ChatRoute(
onNavigateToHistory: () -> Unit,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
+ skipSplash: Boolean = false,
viewModel: ChatViewModel = hiltViewModel(),
) {
val state by viewModel.collectAsState()
@@ -30,7 +31,7 @@ fun ChatRoute(
val defaultErrorMessage = stringResource(R.string.chat_default_error)
val calendarErrorMessage = stringResource(R.string.chat_calendar_error)
- LaunchedEffect(Unit) { viewModel.load(conversationId) }
+ LaunchedEffect(Unit) { viewModel.load(conversationId, skipSplash) }
viewModel.collectSideEffect { effect ->
when (effect) {
@@ -39,7 +40,7 @@ fun ChatRoute(
}
}
- if (state.isLoading && conversationId == null) {
+ if (state.isLoading && conversationId == null && !skipSplash) {
ChatSplashScreen()
} else {
ChatScreen(
diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt
index e959e264..d9a2ef9d 100644
--- a/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt
+++ b/feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt
@@ -45,36 +45,40 @@ class ChatViewModel
)
/** 화면 진입 시 1회. conversationId가 있으면 과거 대화를 먼저 채운다. */
- fun load(conversationId: String?) =
- intent {
- savedStateHandle[KEY_CONVERSATION_ID] = conversationId
- reduce { state.copy(conversationId = conversationId, isLoading = true) }
- val startedAt = System.currentTimeMillis()
+ fun load(
+ conversationId: String?,
+ skipSplash: Boolean = false,
+ ) = intent {
+ savedStateHandle[KEY_CONVERSATION_ID] = conversationId
+ reduce { state.copy(conversationId = conversationId, isLoading = true) }
+ val startedAt = System.currentTimeMillis()
- getChatEntry()
- .onSuccess { entry ->
- reduce {
- state.copy(
- greeting = entry.greeting,
- suggestions = entry.suggestions.toPersistentList(),
- quota = entry.quota,
- )
- }
- }.onFailure { postSideEffect(ChatSideEffect.Error(it)) }
+ getChatEntry()
+ .onSuccess { entry ->
+ reduce {
+ state.copy(
+ greeting = entry.greeting,
+ suggestions = entry.suggestions.toPersistentList(),
+ quota = entry.quota,
+ )
+ }
+ }.onFailure { postSideEffect(ChatSideEffect.Error(it)) }
- if (conversationId != null) {
- getConversationDetail(conversationId)
- .onSuccess { reduce { state.copy(messages = it.messages.toPersistentList()) } }
- .onFailure { postSideEffect(ChatSideEffect.Error(it)) }
- }
+ if (conversationId != null) {
+ getConversationDetail(conversationId)
+ .onSuccess { reduce { state.copy(messages = it.messages.toPersistentList()) } }
+ .onFailure { postSideEffect(ChatSideEffect.Error(it)) }
+ }
- // 로딩 화면이 너무 빨리 깜빡이지 않도록 최소 노출 시간을 보장한다.
+ // 스플래시를 보여주지 않는 진입(skipSplash)은 최소 노출 시간을 지킬 이유가 없다.
+ if (!skipSplash) {
val elapsed = System.currentTimeMillis() - startedAt
if (elapsed < MIN_LOADING_DURATION_MILLIS) {
delay(MIN_LOADING_DURATION_MILLIS - elapsed)
}
- reduce { state.copy(isLoading = false) }
}
+ reduce { state.copy(isLoading = false) }
+ }
fun onInputChange(value: String) =
intent {
diff --git a/feature/home/src/main/java/com/kikidan/home/HomeRoute.kt b/feature/home/src/main/java/com/kikidan/home/HomeRoute.kt
index 0fd8d707..46381353 100644
--- a/feature/home/src/main/java/com/kikidan/home/HomeRoute.kt
+++ b/feature/home/src/main/java/com/kikidan/home/HomeRoute.kt
@@ -18,6 +18,10 @@ fun HomeRoute(
snackbarHostState: SnackbarHostState,
onNavigateToLuckAction: () -> Unit,
onNavigateToReport: (String) -> Unit,
+ onNavigateToNotice: () -> Unit,
+ onNavigateToCompatibility: () -> Unit,
+ onNavigateToDateFortune: () -> Unit,
+ onNavigateToYearFortune: () -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
@@ -38,6 +42,10 @@ fun HomeRoute(
onDetailDismiss = viewModel::closeDetail,
onNavigateToReport = onNavigateToReport,
onNavigateToLuckAction = onNavigateToLuckAction,
+ onNavigateToNotice = onNavigateToNotice,
+ onNavigateToCompatibility = onNavigateToCompatibility,
+ onNavigateToDateFortune = onNavigateToDateFortune,
+ onNavigateToYearFortune = onNavigateToYearFortune,
modifier = modifier,
)
}
diff --git a/feature/home/src/main/java/com/kikidan/home/component/SajuContents.kt b/feature/home/src/main/java/com/kikidan/home/component/SajuContents.kt
index 86d151e8..6fafbd0d 100644
--- a/feature/home/src/main/java/com/kikidan/home/component/SajuContents.kt
+++ b/feature/home/src/main/java/com/kikidan/home/component/SajuContents.kt
@@ -33,29 +33,37 @@ private data class SajuItem(
val iconRes: Int,
val labelRes: Int,
val subtitleRes: Int,
+ val onClick: () -> Unit,
)
-private val SajuItems =
- listOf(
- SajuItem(
- R.drawable.img_home_saju_compatibility,
- R.string.home_saju_compatibility_title,
- R.string.home_saju_compatibility_subtitle,
- ),
- SajuItem(
- R.drawable.img_home_saju_date_fortune,
- R.string.home_saju_date_selection_title,
- R.string.home_saju_date_selection_subtitle,
- ),
- SajuItem(
- R.drawable.img_home_saju_yearly_fortune,
- R.string.home_saju_yearly_fortune_title,
- R.string.home_saju_yearly_fortune_subtitle,
- ),
- )
-
@Composable
-internal fun SajuContents(modifier: Modifier = Modifier) {
+internal fun SajuContents(
+ onNavigateToCompatibility: () -> Unit,
+ onNavigateToDateFortune: () -> Unit,
+ onNavigateToYearFortune: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sajuItems =
+ listOf(
+ SajuItem(
+ R.drawable.img_home_saju_compatibility,
+ R.string.home_saju_compatibility_title,
+ R.string.home_saju_compatibility_subtitle,
+ onNavigateToCompatibility,
+ ),
+ SajuItem(
+ R.drawable.img_home_saju_date_fortune,
+ R.string.home_saju_date_selection_title,
+ R.string.home_saju_date_selection_subtitle,
+ onNavigateToDateFortune,
+ ),
+ SajuItem(
+ R.drawable.img_home_saju_yearly_fortune,
+ R.string.home_saju_yearly_fortune_title,
+ R.string.home_saju_yearly_fortune_subtitle,
+ onNavigateToYearFortune,
+ ),
+ )
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -70,7 +78,7 @@ internal fun SajuContents(modifier: Modifier = Modifier) {
modifier = Modifier.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
- SajuItems.forEach { item -> SajuCard(item = item) }
+ sajuItems.forEach { item -> SajuCard(item = item) }
}
}
}
@@ -88,9 +96,8 @@ private fun SajuCard(
.clip(RoundedCornerShape(16.dp))
.background(TodakunColor.white)
.border(BorderStroke(1.dp, TodakunColor.coolGray100), RoundedCornerShape(16.dp))
- .clickable {
- // TODO(#38): feature:saju-contents 머지 후 연결
- }.padding(horizontal = 20.dp),
+ .clickable(onClick = item.onClick)
+ .padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -126,5 +133,11 @@ private fun SajuCard(
@Preview(showBackground = true)
@Composable
private fun SajuContentsPreview() {
- TodakunTheme { SajuContents() }
+ TodakunTheme {
+ SajuContents(
+ onNavigateToCompatibility = {},
+ onNavigateToDateFortune = {},
+ onNavigateToYearFortune = {},
+ )
+ }
}
diff --git a/feature/home/src/main/java/com/kikidan/home/screen/HomeScreen.kt b/feature/home/src/main/java/com/kikidan/home/screen/HomeScreen.kt
index 1a263fdf..5ca5056b 100644
--- a/feature/home/src/main/java/com/kikidan/home/screen/HomeScreen.kt
+++ b/feature/home/src/main/java/com/kikidan/home/screen/HomeScreen.kt
@@ -59,6 +59,10 @@ internal fun HomeScreen(
onDetailDismiss: () -> Unit,
onNavigateToReport: (String) -> Unit,
onNavigateToLuckAction: () -> Unit,
+ onNavigateToNotice: () -> Unit,
+ onNavigateToCompatibility: () -> Unit,
+ onNavigateToDateFortune: () -> Unit,
+ onNavigateToYearFortune: () -> Unit,
modifier: Modifier = Modifier,
) {
val backdrop = rememberLayerBackdrop()
@@ -94,11 +98,15 @@ internal fun HomeScreen(
scoreLabel = state.scoreLabel,
backdrop = backdrop,
onFortuneReportClick = { onNavigateToReport(state.fortuneId) },
+ onNoticeClick = onNavigateToNotice,
)
HomeContents(
state = state,
onCategoryClick = onCategoryClick,
onNavigateToLuckAction = onNavigateToLuckAction,
+ onNavigateToCompatibility = onNavigateToCompatibility,
+ onNavigateToDateFortune = onNavigateToDateFortune,
+ onNavigateToYearFortune = onNavigateToYearFortune,
)
}
}
@@ -119,6 +127,7 @@ private fun SajuSummary(
scoreLabel: String,
backdrop: LayerBackdrop,
onFortuneReportClick: () -> Unit,
+ onNoticeClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
@@ -131,7 +140,7 @@ private fun SajuSummary(
.statusBarsPadding()
.padding(horizontal = 20.dp),
) {
- HomeHeader()
+ HomeHeader(onNoticeClick = onNoticeClick)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -181,7 +190,10 @@ private fun SajuSummary(
}
@Composable
-private fun HomeHeader(modifier: Modifier = Modifier) {
+private fun HomeHeader(
+ onNoticeClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
Row(
modifier =
modifier
@@ -195,9 +207,7 @@ private fun HomeHeader(modifier: Modifier = Modifier) {
contentDescription = null,
modifier = Modifier.height(24.dp),
)
- IconButton(onClick = {
- // TODO(#38): 알림 화면 미구현, 머지 후 연결
- }) {
+ IconButton(onClick = onNoticeClick) {
Icon(
painter = painterResource(R.drawable.ic_bell),
contentDescription = stringResource(R.string.header_notice_content_description),
@@ -213,6 +223,9 @@ private fun HomeContents(
state: HomeState.Success,
onCategoryClick: (String) -> Unit,
onNavigateToLuckAction: () -> Unit,
+ onNavigateToCompatibility: () -> Unit,
+ onNavigateToDateFortune: () -> Unit,
+ onNavigateToYearFortune: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@@ -228,7 +241,11 @@ private fun HomeContents(
onCategoryClick = onCategoryClick,
)
Spacer(Modifier.height(44.dp))
- SajuContents()
+ SajuContents(
+ onNavigateToCompatibility = onNavigateToCompatibility,
+ onNavigateToDateFortune = onNavigateToDateFortune,
+ onNavigateToYearFortune = onNavigateToYearFortune,
+ )
Spacer(Modifier.height(30.dp))
HomeLuckActionBanner(
onNavigateToLuckAction = onNavigateToLuckAction,
@@ -266,6 +283,10 @@ private fun HomeScreenSuccessPreview() {
onDetailDismiss = {},
onNavigateToReport = {},
onNavigateToLuckAction = {},
+ onNavigateToNotice = {},
+ onNavigateToCompatibility = {},
+ onNavigateToDateFortune = {},
+ onNavigateToYearFortune = {},
)
}
}
diff --git a/feature/mypage/src/main/java/com/kikidan/mypage/home/ui/MyPageHomeRoute.kt b/feature/mypage/src/main/java/com/kikidan/mypage/home/ui/MyPageHomeRoute.kt
index c3f9adab..335ccac9 100644
--- a/feature/mypage/src/main/java/com/kikidan/mypage/home/ui/MyPageHomeRoute.kt
+++ b/feature/mypage/src/main/java/com/kikidan/mypage/home/ui/MyPageHomeRoute.kt
@@ -5,10 +5,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.kikidan.mypage.home.MyPageHomeViewModel
+import com.kikidan.mypage.home.model.MyPageMenuType
import org.orbitmvi.orbit.compose.collectAsState
@Composable
fun MyPageHomeRoute(
+ onNavigateToEdit: () -> Unit,
+ onNavigateToMansaeryeok: () -> Unit,
+ onNavigateToPartnerSajuManagement: () -> Unit,
+ onNavigateToNotificationSetting: () -> Unit,
+ onNavigateToAppSetting: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MyPageHomeViewModel = hiltViewModel(),
) {
@@ -17,5 +23,22 @@ fun MyPageHomeRoute(
MyPageHomeScreen(
uiState = uiState,
modifier = modifier,
+ onEditClick = onNavigateToEdit,
+ onViewMansaeryeokClick = onNavigateToMansaeryeok,
+ onMenuItemClick = { menuType ->
+ when (menuType) {
+ MyPageMenuType.SAJU_INFO -> onNavigateToPartnerSajuManagement()
+
+ MyPageMenuType.NOTIFICATION_SETTING -> onNavigateToNotificationSetting()
+
+ MyPageMenuType.APP_SETTING -> onNavigateToAppSetting()
+
+ // TODO(#후속이슈): 문의하기 연결
+ MyPageMenuType.INQUIRY -> Unit
+
+ // TODO(#후속이슈): 로그아웃 UseCase 연동
+ MyPageMenuType.LOGOUT -> Unit
+ }
+ },
)
}
diff --git a/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/PartnerSajuFormViewModel.kt b/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/PartnerSajuFormViewModel.kt
index 5d7a7635..d8d09cc0 100644
--- a/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/PartnerSajuFormViewModel.kt
+++ b/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/PartnerSajuFormViewModel.kt
@@ -25,19 +25,27 @@ import javax.inject.Inject
class PartnerSajuFormViewModel
@Inject
constructor(
- savedStateHandle: SavedStateHandle,
+ private val savedStateHandle: SavedStateHandle,
private val getPartnerSajuUseCase: GetPartnerSajuUseCase,
private val registerPartnerSajuUseCase: RegisterPartnerSajuUseCase,
private val updatePartnerSajuUseCase: UpdatePartnerSajuUseCase,
) : ViewModel(),
ContainerHost {
- private val linkId: String? = savedStateHandle[LINK_ID_KEY]
+ private var linkId: String? = savedStateHandle[LINK_ID_KEY]
override val container: Container =
container(PartnerSajuFormUiState.Loading) {
loadForm()
}
+ /** 화면 진입 시 1회. Navigation3는 라우트 인자를 SavedStateHandle에 자동 주입하지 않아 명시적으로 전달받는다. */
+ fun load(linkId: String?) =
+ intent {
+ this@PartnerSajuFormViewModel.linkId = linkId
+ savedStateHandle[LINK_ID_KEY] = linkId
+ loadForm()
+ }
+
private fun loadForm() =
intent {
val targetLinkId = linkId
diff --git a/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/ui/PartnerSajuFormRoute.kt b/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/ui/PartnerSajuFormRoute.kt
index f653b7f4..d38d1120 100644
--- a/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/ui/PartnerSajuFormRoute.kt
+++ b/feature/mypage/src/main/java/com/kikidan/mypage/partner/form/ui/PartnerSajuFormRoute.kt
@@ -1,6 +1,7 @@
package com.kikidan.mypage.partner.form.ui
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
@@ -13,10 +14,13 @@ import org.orbitmvi.orbit.compose.collectSideEffect
fun PartnerSajuFormRoute(
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
+ partnerLinkId: String? = null,
viewModel: PartnerSajuFormViewModel = hiltViewModel(),
) {
val uiState by viewModel.collectAsState()
+ LaunchedEffect(partnerLinkId) { viewModel.load(partnerLinkId) }
+
viewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
is PartnerSajuFormSideEffect.NavigateBack -> onNavigateBack()
diff --git a/feature/notification/src/main/java/com/kikidan/notification/NotificationRoute.kt b/feature/notification/src/main/java/com/kikidan/notification/NotificationRoute.kt
index c19a13eb..4486c0c2 100644
--- a/feature/notification/src/main/java/com/kikidan/notification/NotificationRoute.kt
+++ b/feature/notification/src/main/java/com/kikidan/notification/NotificationRoute.kt
@@ -16,6 +16,7 @@ import org.orbitmvi.orbit.compose.collectSideEffect
@Composable
fun NotificationRoute(
onBackClick: () -> Unit,
+ onNavigateToDeepLink: (String) -> Unit,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
viewModel: NotificationViewModel = hiltViewModel(),
@@ -28,6 +29,7 @@ fun NotificationRoute(
viewModel.collectSideEffect { effect ->
when (effect) {
is NotificationSideEffect.Error -> snackbarHostState.showSnackbar(defaultErrorMessage)
+ is NotificationSideEffect.NavigateToDeepLink -> onNavigateToDeepLink(effect.deepLink)
}
}
diff --git a/feature/notification/src/main/java/com/kikidan/notification/NotificationViewModel.kt b/feature/notification/src/main/java/com/kikidan/notification/NotificationViewModel.kt
index 9e31aee6..11fa9037 100644
--- a/feature/notification/src/main/java/com/kikidan/notification/NotificationViewModel.kt
+++ b/feature/notification/src/main/java/com/kikidan/notification/NotificationViewModel.kt
@@ -1,6 +1,7 @@
package com.kikidan.notification
import androidx.lifecycle.ViewModel
+import com.kikidan.domain.model.notification.Notification
import com.kikidan.domain.usecase.GetNotificationsUseCase
import com.kikidan.domain.usecase.MarkNotificationAsReadUseCase
import com.kikidan.notification.model.NotificationSideEffect
@@ -22,11 +23,15 @@ class NotificationViewModel
ContainerHost {
override val container = container(NotificationState())
+ // 화면 표시용 NotificationUiModel에는 deepLink가 없어 클릭 시 원본 목록에서 조회한다.
+ private var notifications: List = emptyList()
+
fun load() =
intent {
reduce { state.copy(isLoading = true) }
getNotifications()
.onSuccess { summary ->
+ notifications = summary.notifications
reduce {
state.copy(
isLoading = false,
@@ -51,6 +56,10 @@ class NotificationViewModel
.toPersistentList(),
)
}
+ notifications
+ .firstOrNull { it.id == notificationId }
+ ?.deepLink
+ ?.let { deepLink -> postSideEffect(NotificationSideEffect.NavigateToDeepLink(deepLink)) }
}.onFailure { postSideEffect(NotificationSideEffect.Error(it)) }
}
}
diff --git a/feature/notification/src/main/java/com/kikidan/notification/component/PushNotificationBanner.kt b/feature/notification/src/main/java/com/kikidan/notification/component/PushNotificationBanner.kt
new file mode 100644
index 00000000..771a9717
--- /dev/null
+++ b/feature/notification/src/main/java/com/kikidan/notification/component/PushNotificationBanner.kt
@@ -0,0 +1,160 @@
+package com.kikidan.notification.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+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.rememberUpdatedState
+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.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.notification.NotificationType
+import com.kikidan.domain.model.notification.PushNotificationEvent
+import kotlinx.coroutines.delay
+
+private const val ANIMATION_DURATION_MILLIS = 300
+
+@Composable
+fun PushNotificationBanner(
+ event: PushNotificationEvent?,
+ onDismiss: () -> Unit,
+ onClick: (PushNotificationEvent) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var visible by remember { mutableStateOf(false) }
+ val currentOnDismiss by rememberUpdatedState(onDismiss)
+
+ LaunchedEffect(event) {
+ if (event != null) {
+ visible = true
+ delay(3_000L)
+ visible = false
+ delay(ANIMATION_DURATION_MILLIS.toLong())
+ currentOnDismiss()
+ }
+ }
+
+ AnimatedVisibility(
+ visible = visible,
+ enter = slideInVertically(animationSpec = tween(ANIMATION_DURATION_MILLIS)) { -it } + fadeIn(),
+ exit = slideOutVertically(animationSpec = tween(ANIMATION_DURATION_MILLIS)) { -it } + fadeOut(),
+ modifier = modifier,
+ ) {
+ event?.let {
+ PushNotificationBannerContent(
+ event = it,
+ onClick = { onClick(it) },
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun PushNotificationBannerContent(
+ event: PushNotificationEvent,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(TodakunColor.blackOpacity80)
+ .clickable { onClick() }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ThumbnailImage()
+ Spacer(Modifier.width(12.dp))
+ Column {
+ Text(
+ text = event.title,
+ style = TodakunTypography.caption1Medium,
+ color = TodakunColor.white,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(2.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = event.body,
+ style = TodakunTypography.caption2Regular,
+ color = TodakunColor.white,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(weight = 1f, fill = false),
+ )
+ Spacer(Modifier.width(2.dp))
+ Box(
+ modifier =
+ Modifier
+ .size(4.dp)
+ .clip(CircleShape)
+ .background(TodakunColor.red400),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ThumbnailImage(modifier: Modifier = Modifier) {
+ Image(
+ painter = painterResource(id = R.drawable.img_todak_chat_thumbnail),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = modifier.size(40.dp).clip(RoundedCornerShape(12.dp)),
+ )
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun PushNotificationBannerPreview() {
+ TodakunTheme {
+ PushNotificationBannerContent(
+ event =
+ PushNotificationEvent(
+ notificationId = "n-1",
+ type = NotificationType.AI_COMPLETE,
+ title = "토닥이",
+ body = "질문에 대한 답변이 도착했어요",
+ deepLink = null,
+ ),
+ onClick = {},
+ )
+ }
+}
diff --git a/feature/notification/src/main/java/com/kikidan/notification/fcm/TodakunFirebaseMessagingService.kt b/feature/notification/src/main/java/com/kikidan/notification/fcm/TodakunFirebaseMessagingService.kt
index 7ce02729..c00acaac 100644
--- a/feature/notification/src/main/java/com/kikidan/notification/fcm/TodakunFirebaseMessagingService.kt
+++ b/feature/notification/src/main/java/com/kikidan/notification/fcm/TodakunFirebaseMessagingService.kt
@@ -3,6 +3,7 @@ package com.kikidan.notification.fcm
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
+import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.Lifecycle
@@ -52,7 +53,6 @@ class TodakunFirebaseMessagingService : FirebaseMessagingService() {
.lifecycle.currentState
.isAtLeast(Lifecycle.State.STARTED)
- // 백그라운드의 경우 OnMessageReceived가 아닌 Firebase가 직접 푸시알림 발송 (Notification 필드의 존재)
if (isForeground) {
serviceScope.launch {
PushNotificationEventFlow.emit(
@@ -65,9 +65,49 @@ class TodakunFirebaseMessagingService : FirebaseMessagingService() {
),
)
}
+ } else {
+ // 프로세스가 살아있는 백그라운드에서는 직접 시스템 알림을 만들어 딥링크를 PendingIntent에 싣는다.
+ // 프로세스가 완전히 종료된 상태에서는 이 콜백 자체가 호출되지 않아 커버 범위 밖이다.
+ showSystemNotification(notificationId, title, body, deepLink)
}
}
+ @SuppressLint("MissingPermission")
+ private fun showSystemNotification(
+ notificationId: String,
+ title: String,
+ body: String,
+ deepLink: String?,
+ ) {
+ val contentIntent =
+ deepLink?.let { link ->
+ Intent(Intent.ACTION_VIEW, Uri.parse(link)).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ }
+ val pendingIntent =
+ contentIntent?.let {
+ PendingIntent.getActivity(
+ this,
+ notificationId.hashCode(),
+ it,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ val notification =
+ NotificationCompat
+ .Builder(this, getString(R.string.default_notification_channel_id))
+ .setSmallIcon(R.drawable.ic_bell)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setAutoCancel(true)
+ .apply { pendingIntent?.let(::setContentIntent) }
+ .build()
+
+ NotificationManagerCompat.from(this).notify(notificationId.hashCode(), notification)
+ }
+
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
diff --git a/feature/notification/src/main/java/com/kikidan/notification/model/NotificationSideEffect.kt b/feature/notification/src/main/java/com/kikidan/notification/model/NotificationSideEffect.kt
index 9dc0c5d6..4095ad95 100644
--- a/feature/notification/src/main/java/com/kikidan/notification/model/NotificationSideEffect.kt
+++ b/feature/notification/src/main/java/com/kikidan/notification/model/NotificationSideEffect.kt
@@ -4,4 +4,8 @@ sealed interface NotificationSideEffect {
data class Error(
val e: Throwable,
) : NotificationSideEffect
+
+ data class NavigateToDeepLink(
+ val deepLink: String,
+ ) : NotificationSideEffect
}
diff --git a/feature/notification/src/test/java/com/kikidan/notification/NotificationViewModelTest.kt b/feature/notification/src/test/java/com/kikidan/notification/NotificationViewModelTest.kt
index b75fa68f..09c5c165 100644
--- a/feature/notification/src/test/java/com/kikidan/notification/NotificationViewModelTest.kt
+++ b/feature/notification/src/test/java/com/kikidan/notification/NotificationViewModelTest.kt
@@ -97,6 +97,47 @@ class NotificationViewModelTest {
}
}
+ @Test
+ fun `onNotificationClick 성공 시 해당 알림의 deepLink로 NavigateToDeepLink가 발행된다`() =
+ runTest {
+ val fakeNotificationRepository =
+ FakeNotificationRepository().apply {
+ notificationsResult =
+ Result.success(
+ NotificationSummary(
+ unreadCount = 1,
+ notifications =
+ listOf(
+ Notification(
+ id = "n-1",
+ type = NotificationType.FORTUNE,
+ title = "오늘의 운세가 도착했어요.",
+ content = "내용",
+ deepLink = "todakun://fortune/today",
+ isRead = false,
+ createdAt = Instant.now(),
+ ),
+ ),
+ ),
+ )
+ }
+ val vm = viewModel(fakeNotificationRepository)
+
+ vm.test(this) {
+ containerHost.load()
+ awaitState()
+
+ containerHost.onNotificationClick("n-1")
+ awaitState()
+ val sideEffect = awaitSideEffect()
+ assertTrue(sideEffect is NotificationSideEffect.NavigateToDeepLink)
+ assertEquals(
+ "todakun://fortune/today",
+ (sideEffect as NotificationSideEffect.NavigateToDeepLink).deepLink,
+ )
+ }
+ }
+
private fun viewModel(fakeNotificationRepository: FakeNotificationRepository): NotificationViewModel =
NotificationViewModel(
getNotifications = GetNotificationsUseCase(fakeNotificationRepository),
diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsRoute.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsRoute.kt
index ae651563..9d26a7d2 100644
--- a/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsRoute.kt
+++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsRoute.kt
@@ -50,8 +50,8 @@ fun TermsRoute(
description = stringResource(id = R.string.onboarding_terms_exit_description),
confirmText = stringResource(id = R.string.onboarding_terms_exit_dismiss),
dismissText = stringResource(id = R.string.onboarding_terms_exit_confirm),
- onConfirm = viewModel::confirmExit,
- onDismiss = viewModel::dismissDialog,
+ onConfirm = viewModel::dismissDialog,
+ onDismiss = viewModel::confirmExit,
)
}