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, ) }