diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6141d8bc..75fb02ed 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -82,6 +82,7 @@ android {
}
dependencies {
+ implementation(project(":feature:onboarding"))
implementation(projects.domain)
implementation(projects.data)
implementation(projects.core.common)
@@ -91,6 +92,7 @@ dependencies {
implementation(projects.feature.chat)
implementation(projects.feature.calendar)
implementation(projects.feature.login)
+ implementation(projects.feature.onboarding)
implementation(projects.feature.setting)
implementation(projects.feature.webview)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 279e900c..286c3b05 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt
index bfeef771..d4dd7018 100644
--- a/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt
+++ b/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt
@@ -1,5 +1,9 @@
package com.gamss.android.app.navigation
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -14,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
@@ -23,6 +28,8 @@ import com.gamss.android.app.main.MainViewModel
import com.gamss.android.domain.auth.SessionState
import com.gamss.android.feature.login.LoginScreen
import com.gamss.android.feature.login.navigation.LoginKey
+import com.gamss.android.feature.onboarding.OnboardingScreen
+import com.gamss.android.feature.onboarding.navigation.OnboardingKey
import org.orbitmvi.orbit.compose.collectAsState
@Composable
@@ -40,6 +47,7 @@ fun GamssRootNavHost(
CircularProgressIndicator()
}
}
+
else -> RootNavDisplay(
sessionState = state.sessionState,
useCardFeature = state.useCardFeature,
@@ -54,25 +62,45 @@ private fun RootNavDisplay(
) {
val initialKey = if (sessionState == SessionState.Authenticated) MainKey else LoginKey
val backStack = rememberNavBackStack(initialKey)
+ val notificationPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = {},
+ )
- LaunchedEffect(sessionState) {
- val destination = when (sessionState) {
- SessionState.Authenticated -> MainKey
- SessionState.Unauthenticated -> LoginKey
- SessionState.Loading -> return@LaunchedEffect
- }
+ fun replaceRoot(destination: NavKey) {
if (backStack.lastOrNull() != destination) {
backStack.clear()
backStack.add(destination)
}
}
+ LaunchedEffect(sessionState) {
+ // 로그인 성공 시에는 LoginResult.isFirstLogin 분기를 기다린다. 세션 상태만 보고 Main으로
+ // 보내면 온보딩 콜백과 경쟁하므로, 이 effect는 세션 만료/로그아웃만 처리한다.
+ if (sessionState == SessionState.Unauthenticated) {
+ replaceRoot(LoginKey)
+ }
+ }
+
NavDisplay(
entries = backStack.map(
entryProvider {
entry {
LoginScreen(
googleWebClientId = stringResource(R.string.default_web_client_id),
+ onLoginSuccess = { isFirstLogin ->
+ replaceRoot(if (isFirstLogin) OnboardingKey else MainKey)
+ },
+ )
+ }
+ entry {
+ OnboardingScreen(
+ onComplete = { replaceRoot(MainKey) },
+ onNotificationPermissionRequest = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ },
)
}
entry { MainScreen(useCardFeature = useCardFeature) }
diff --git a/feature/login/src/main/java/com/gamss/android/feature/login/LoginScreen.kt b/feature/login/src/main/java/com/gamss/android/feature/login/LoginScreen.kt
index 4f9aabcd..e9e77cc4 100644
--- a/feature/login/src/main/java/com/gamss/android/feature/login/LoginScreen.kt
+++ b/feature/login/src/main/java/com/gamss/android/feature/login/LoginScreen.kt
@@ -53,6 +53,7 @@ private val ButtonBottomSpacing = 54.dp
@Composable
fun LoginScreen(
googleWebClientId: String,
+ onLoginSuccess: (isFirstLogin: Boolean) -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.collectAsState()
@@ -76,6 +77,9 @@ fun LoginScreen(
viewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
+ is LoginSideEffect.LoginSucceeded ->
+ onLoginSuccess(sideEffect.isFirstLogin)
+
is LoginSideEffect.ShowToast ->
Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show()
}
diff --git a/feature/login/src/main/java/com/gamss/android/feature/login/LoginSideEffect.kt b/feature/login/src/main/java/com/gamss/android/feature/login/LoginSideEffect.kt
index 897f42d2..0c6d75a8 100644
--- a/feature/login/src/main/java/com/gamss/android/feature/login/LoginSideEffect.kt
+++ b/feature/login/src/main/java/com/gamss/android/feature/login/LoginSideEffect.kt
@@ -2,4 +2,6 @@ package com.gamss.android.feature.login
sealed interface LoginSideEffect {
data class ShowToast(val message: String) : LoginSideEffect
+
+ data class LoginSucceeded(val isFirstLogin: Boolean) : LoginSideEffect
}
diff --git a/feature/login/src/main/java/com/gamss/android/feature/login/LoginViewModel.kt b/feature/login/src/main/java/com/gamss/android/feature/login/LoginViewModel.kt
index fac93360..e3741d6b 100644
--- a/feature/login/src/main/java/com/gamss/android/feature/login/LoginViewModel.kt
+++ b/feature/login/src/main/java/com/gamss/android/feature/login/LoginViewModel.kt
@@ -23,9 +23,7 @@ class LoginViewModel @Inject constructor(
when (val result = loginUseCase(googleIdToken)) {
is AppResult.Success -> {
reduce { state.copy(isLoading = false) }
- if (result.data.isFirstLogin) {
- // 온보딩 화면이 정의되면 첫 로그인 사용자를 온보딩 플로우로 이동시킨다.
- }
+ postSideEffect(LoginSideEffect.LoginSucceeded(result.data.isFirstLogin))
}
is AppResult.Failure -> {
diff --git a/feature/login/src/test/java/com/gamss/android/feature/login/LoginViewModelTest.kt b/feature/login/src/test/java/com/gamss/android/feature/login/LoginViewModelTest.kt
index d3c1b454..41c811d7 100644
--- a/feature/login/src/test/java/com/gamss/android/feature/login/LoginViewModelTest.kt
+++ b/feature/login/src/test/java/com/gamss/android/feature/login/LoginViewModelTest.kt
@@ -60,11 +60,25 @@ class LoginViewModelTest {
containerHost.onGoogleCredentialResolved("google-id-token")
expectState { copy(googleSignInRequestId = null) }
expectState { copy(isLoading = false) }
+ expectSideEffect(LoginSideEffect.LoginSucceeded(isFirstLogin = false))
}
coVerify(exactly = 1) { loginUseCase("google-id-token") }
}
+ @Test
+ fun `첫 로그인에 성공하면 온보딩 분기에 사용할 결과를 전달한다`() = runTest {
+ coEvery { loginUseCase(any()) } returns AppResult.Success(LoginResult(isFirstLogin = true))
+
+ viewModel().test(this) {
+ containerHost.onGoogleCredentialResolved("google-id-token")
+
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(LoginSideEffect.LoginSucceeded(isFirstLogin = true))
+ }
+ }
+
@Test
fun `Credential 요청이 실패하면 요청 id와 로딩 상태를 초기화하고 토스트를 노출한다`() = runTest {
viewModel().test(this) {
diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts
new file mode 100644
index 00000000..26d04176
--- /dev/null
+++ b/feature/onboarding/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ alias(libs.plugins.gamss.android.feature)
+}
+
+android {
+ namespace = "com.gamss.android.feature.onboarding"
+}
diff --git a/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/OnboardingScreen.kt b/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/OnboardingScreen.kt
new file mode 100644
index 00000000..3a66dcde
--- /dev/null
+++ b/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/OnboardingScreen.kt
@@ -0,0 +1,322 @@
+package com.gamss.android.feature.onboarding
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.layout.Layout
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gamss.android.core.designsystem.button.GamssButton
+import com.gamss.android.core.designsystem.button.GamssButtonVariant
+import com.gamss.android.core.designsystem.component.GamssText
+import com.gamss.android.core.designsystem.theme.GamssTheme
+import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation
+import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationContent
+import com.gamss.android.feature.onboarding.component.NotificationPermissionDialog
+import kotlinx.coroutines.launch
+
+private val IllustrationMaxWidth = 340.dp
+private val IllustrationMaxHeight = 260.dp
+private val IllustrationVerticalOffset = (-40).dp
+private val ScreenHorizontalPadding = 20.dp
+private val MessageTopSpacing = 32.dp
+private val IndicatorTopSpacing = 48.dp
+private val IndicatorSize = 6.dp
+private val IndicatorSpacing = 4.dp
+private val ButtonTopSpacing = 28.dp
+private val ButtonBottomSpacing = 24.dp
+
+private enum class OnboardingPage(
+ @param:DrawableRes val illustrationRes: Int,
+ @param:StringRes val illustrationDescriptionRes: Int,
+ @param:StringRes val messageRes: Int,
+) {
+ Write(
+ illustrationRes = R.drawable.img_onboarding_step1,
+ illustrationDescriptionRes = R.string.onboarding_step1_description,
+ messageRes = R.string.onboarding_step1_message,
+ ),
+ Talk(
+ illustrationRes = R.drawable.image_onboarding_step2,
+ illustrationDescriptionRes = R.string.onboarding_step2_description,
+ messageRes = R.string.onboarding_step2_message,
+ ),
+ Empty(
+ illustrationRes = R.drawable.image_onboarding_step3,
+ illustrationDescriptionRes = R.string.onboarding_step3_description,
+ messageRes = R.string.onboarding_step3_message,
+ ),
+}
+
+/**
+ * GAMSS의 첫 사용 흐름입니다.
+ *
+ * 알림 런타임 권한 요청은 Activity가 소유하도록 [onNotificationPermissionRequest]로 위임하고,
+ * 온보딩 종료 시점은 권한 선택과 무관하게 [onComplete]로 알립니다.
+ */
+@Composable
+fun OnboardingScreen(
+ onComplete: () -> Unit,
+ onNotificationPermissionRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ OnboardingContent(
+ onComplete = onComplete,
+ onNotificationPermissionRequest = onNotificationPermissionRequest,
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun OnboardingContent(
+ onComplete: () -> Unit,
+ onNotificationPermissionRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ initialPage: Int = 0,
+) {
+ val pages = OnboardingPage.entries
+ val pagerState = rememberPagerState(initialPage = initialPage) { pages.size }
+ val coroutineScope = rememberCoroutineScope()
+ var showPermissionDialog by remember { mutableStateOf(false) }
+
+ if (showPermissionDialog) {
+ NotificationPermissionDialog(
+ onDismiss = { showPermissionDialog = false },
+ onDisagree = {
+ showPermissionDialog = false
+ onComplete()
+ },
+ onAgree = {
+ showPermissionDialog = false
+ onNotificationPermissionRequest()
+ onComplete()
+ },
+ )
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(GamssTheme.colors.background)
+ .safeDrawingPadding(),
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.Top,
+ ) { index ->
+ OnboardingPageContent(page = pages[index])
+ }
+
+ OnboardingTopBar(
+ showBack = pagerState.currentPage > 0,
+ showSkip = pagerState.currentPage < pages.lastIndex,
+ onBackClick = {
+ coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
+ },
+ onSkipClick = onComplete,
+ modifier = Modifier.align(Alignment.TopCenter),
+ )
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth(),
+ ) {
+ PageIndicator(
+ currentPage = pagerState.currentPage,
+ pageCount = pages.size,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = IndicatorTopSpacing),
+ )
+
+ OnboardingActionButton(
+ isLastPage = pagerState.currentPage == pages.lastIndex,
+ onNextClick = {
+ coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
+ },
+ onStartClick = { showPermissionDialog = true },
+ )
+ }
+ }
+}
+
+@Composable
+private fun OnboardingActionButton(
+ isLastPage: Boolean,
+ onNextClick: () -> Unit,
+ onStartClick: () -> Unit,
+) {
+ GamssButton(
+ label = stringResource(if (isLastPage) R.string.onboarding_start else R.string.onboarding_next),
+ onClick = if (isLastPage) onStartClick else onNextClick,
+ modifier = Modifier
+ .padding(
+ start = ScreenHorizontalPadding,
+ top = ButtonTopSpacing,
+ end = ScreenHorizontalPadding,
+ bottom = ButtonBottomSpacing,
+ )
+ .fillMaxWidth(),
+ variant = if (isLastPage) GamssButtonVariant.Destructive else GamssButtonVariant.Primary,
+ )
+}
+
+@Composable
+private fun OnboardingTopBar(
+ showBack: Boolean,
+ showSkip: Boolean,
+ onBackClick: () -> Unit,
+ onSkipClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier.fillMaxWidth()) {
+ GamssTopNavigation(
+ content = GamssTopNavigationContent.None,
+ backgroundColor = GamssTheme.colors.background,
+ showLeftIcon = showBack,
+ leftIconContentDescription = stringResource(R.string.onboarding_back_description),
+ onLeftIconClick = onBackClick,
+ )
+ if (showSkip) {
+ GamssText(
+ text = stringResource(R.string.onboarding_skip),
+ style = GamssTheme.typography.body3Medium,
+ color = GamssTheme.colors.gray900,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .clip(CircleShape)
+ .clickable(onClick = onSkipClick)
+ .padding(horizontal = ScreenHorizontalPadding, vertical = 12.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun OnboardingPageContent(page: OnboardingPage) {
+ Layout(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = ScreenHorizontalPadding),
+ content = {
+ Image(
+ painter = painterResource(page.illustrationRes),
+ contentDescription = stringResource(page.illustrationDescriptionRes),
+ contentScale = ContentScale.Fit,
+ )
+ GamssText(
+ text = stringResource(page.messageRes),
+ style = GamssTheme.typography.body2Regular,
+ color = GamssTheme.colors.gray900,
+ textAlign = TextAlign.Center,
+ )
+ },
+ ) { measurables, constraints ->
+ val illustration = measurables[0].measure(
+ constraints.copy(
+ minWidth = 0,
+ maxWidth = minOf(constraints.maxWidth, IllustrationMaxWidth.roundToPx()),
+ minHeight = 0,
+ maxHeight = minOf(constraints.maxHeight, IllustrationMaxHeight.roundToPx()),
+ ),
+ )
+ val message = measurables[1].measure(
+ constraints.copy(minWidth = 0, minHeight = 0),
+ )
+ val illustrationX = (constraints.maxWidth - illustration.width) / 2
+ val centeredIllustrationY = (constraints.maxHeight - illustration.height) / 2
+ val illustrationY = (centeredIllustrationY + IllustrationVerticalOffset.roundToPx()).coerceAtLeast(0)
+ val messageX = (constraints.maxWidth - message.width) / 2
+ val messageY = (illustrationY + illustration.height + MessageTopSpacing.roundToPx())
+ .coerceAtMost(constraints.maxHeight - message.height)
+
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ illustration.placeRelative(illustrationX, illustrationY)
+ message.placeRelative(messageX, messageY)
+ }
+ }
+}
+
+@Composable
+private fun PageIndicator(
+ currentPage: Int,
+ pageCount: Int,
+ modifier: Modifier = Modifier,
+) {
+ val description = stringResource(R.string.onboarding_page_description, currentPage + 1, pageCount)
+ Row(
+ modifier = modifier.semantics { contentDescription = description },
+ horizontalArrangement = Arrangement.spacedBy(IndicatorSpacing),
+ ) {
+ repeat(pageCount) { index ->
+ Box(
+ modifier = Modifier
+ .size(IndicatorSize)
+ .clip(CircleShape)
+ .background(
+ if (index == currentPage) {
+ GamssTheme.colors.gray700
+ } else {
+ GamssTheme.colors.gray200
+ },
+ ),
+ )
+ }
+ }
+}
+
+@Preview(name = "Onboarding - Step 1", showBackground = true, heightDp = 800)
+@Suppress("UnusedPrivateMember")
+@Composable
+private fun OnboardingStep1Preview() {
+ GamssTheme {
+ OnboardingContent(
+ onComplete = {},
+ onNotificationPermissionRequest = {},
+ )
+ }
+}
+
+@Preview(name = "Onboarding - Step 3", showBackground = true, heightDp = 800)
+@Suppress("UnusedPrivateMember")
+@Composable
+private fun OnboardingStep3Preview() {
+ GamssTheme {
+ OnboardingContent(
+ onComplete = {},
+ onNotificationPermissionRequest = {},
+ initialPage = OnboardingPage.entries.lastIndex,
+ )
+ }
+}
diff --git a/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/component/NotificationPermissionDialog.kt b/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/component/NotificationPermissionDialog.kt
new file mode 100644
index 00000000..8cdcb8f5
--- /dev/null
+++ b/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/component/NotificationPermissionDialog.kt
@@ -0,0 +1,187 @@
+package com.gamss.android.feature.onboarding.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+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.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.gamss.android.core.designsystem.button.GamssButton
+import com.gamss.android.core.designsystem.button.GamssButtonVariant
+import com.gamss.android.core.designsystem.component.GamssText
+import com.gamss.android.core.designsystem.theme.GamssTheme
+import com.gamss.android.feature.onboarding.R
+
+private val DialogMaxWidth = 360.dp
+private val DialogHorizontalMargin = 20.dp
+private val PermissionPanelTopSpacing = 24.dp
+private val PermissionIconSize = 24.dp
+private val PermissionInfoIconSize = 20.dp
+
+@Composable
+internal fun NotificationPermissionDialog(
+ onDismiss: () -> Unit,
+ onDisagree: () -> Unit,
+ onAgree: () -> Unit,
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = DialogHorizontalMargin),
+ contentAlignment = Alignment.Center,
+ ) {
+ PermissionDialogContent(
+ onDisagree = onDisagree,
+ onAgree = onAgree,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PermissionDialogContent(
+ onDisagree: () -> Unit,
+ onAgree: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .widthIn(max = DialogMaxWidth)
+ .fillMaxWidth()
+ .background(
+ color = GamssTheme.colors.gray025,
+ shape = RoundedCornerShape(GamssTheme.radius.radius500),
+ )
+ .padding(GamssTheme.spacing.spacing500),
+ ) {
+ GamssText(
+ text = stringResource(R.string.onboarding_permission_title),
+ style = GamssTheme.typography.title3,
+ color = GamssTheme.colors.gray950,
+ )
+ Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200))
+ GamssText(
+ text = stringResource(R.string.onboarding_permission_subtitle),
+ style = GamssTheme.typography.body3Medium,
+ color = GamssTheme.colors.gray500,
+ )
+ Spacer(modifier = Modifier.height(PermissionPanelTopSpacing))
+ PermissionPanel()
+ Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing500))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100),
+ ) {
+ GamssButton(
+ label = stringResource(R.string.onboarding_permission_disagree),
+ onClick = onDisagree,
+ modifier = Modifier.weight(1f),
+ variant = GamssButtonVariant.Secondary,
+ )
+ GamssButton(
+ label = stringResource(R.string.onboarding_permission_agree),
+ onClick = onAgree,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun PermissionPanel() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = GamssTheme.colors.gray050,
+ shape = RoundedCornerShape(GamssTheme.radius.radius300),
+ )
+ .padding(GamssTheme.spacing.spacing300),
+ verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing300),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ color = GamssTheme.colors.gray300,
+ shape = RoundedCornerShape(GamssTheme.radius.radius200),
+ )
+ .padding(GamssTheme.spacing.spacing300),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing200),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_notification),
+ contentDescription = stringResource(R.string.onboarding_notification_permission_description),
+ tint = GamssTheme.colors.red,
+ modifier = Modifier.size(PermissionIconSize),
+ )
+ GamssText(
+ text = stringResource(R.string.onboarding_notification_permission_name),
+ style = GamssTheme.typography.body4Medium,
+ color = GamssTheme.colors.gray900,
+ )
+ GamssText(
+ text = stringResource(R.string.onboarding_notification_permission_reason),
+ style = GamssTheme.typography.body4Medium,
+ color = GamssTheme.colors.gray500,
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_info),
+ contentDescription = null,
+ tint = GamssTheme.colors.gray300,
+ modifier = Modifier.size(PermissionInfoIconSize),
+ )
+ GamssText(
+ text = stringResource(R.string.onboarding_permission_optional),
+ style = GamssTheme.typography.caption2,
+ color = GamssTheme.colors.gray400,
+ )
+ }
+ }
+}
+
+@Preview(name = "Notification permission", showBackground = true, widthDp = 402, heightDp = 874)
+@Suppress("UnusedPrivateMember")
+@Composable
+private fun NotificationPermissionDialogPreview() {
+ GamssTheme {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(GamssTheme.colors.gray700)
+ .padding(DialogHorizontalMargin),
+ contentAlignment = Alignment.Center,
+ ) {
+ PermissionDialogContent(onDisagree = {}, onAgree = {})
+ }
+ }
+}
diff --git a/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/navigation/OnboardingKey.kt b/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/navigation/OnboardingKey.kt
new file mode 100644
index 00000000..af935ec3
--- /dev/null
+++ b/feature/onboarding/src/main/java/com/gamss/android/feature/onboarding/navigation/OnboardingKey.kt
@@ -0,0 +1,7 @@
+package com.gamss.android.feature.onboarding.navigation
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+@Serializable
+data object OnboardingKey : NavKey
diff --git a/feature/onboarding/src/main/res/drawable-xhdpi/image_onboarding_step2.webp b/feature/onboarding/src/main/res/drawable-xhdpi/image_onboarding_step2.webp
new file mode 100644
index 00000000..06ff3925
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xhdpi/image_onboarding_step2.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xhdpi/image_onboarding_step3.webp b/feature/onboarding/src/main/res/drawable-xhdpi/image_onboarding_step3.webp
new file mode 100644
index 00000000..e33e68b2
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xhdpi/image_onboarding_step3.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xhdpi/img_onboarding_step1.webp b/feature/onboarding/src/main/res/drawable-xhdpi/img_onboarding_step1.webp
new file mode 100644
index 00000000..62cbe635
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xhdpi/img_onboarding_step1.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xxhdpi/image_onboarding_step2.webp b/feature/onboarding/src/main/res/drawable-xxhdpi/image_onboarding_step2.webp
new file mode 100644
index 00000000..45d7b83d
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xxhdpi/image_onboarding_step2.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xxhdpi/image_onboarding_step3.webp b/feature/onboarding/src/main/res/drawable-xxhdpi/image_onboarding_step3.webp
new file mode 100644
index 00000000..766c699b
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xxhdpi/image_onboarding_step3.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xxhdpi/img_onboarding_step1.webp b/feature/onboarding/src/main/res/drawable-xxhdpi/img_onboarding_step1.webp
new file mode 100644
index 00000000..b7587db6
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xxhdpi/img_onboarding_step1.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xxxhdpi/image_onboarding_step2.webp b/feature/onboarding/src/main/res/drawable-xxxhdpi/image_onboarding_step2.webp
new file mode 100644
index 00000000..4b227110
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xxxhdpi/image_onboarding_step2.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xxxhdpi/image_onboarding_step3.webp b/feature/onboarding/src/main/res/drawable-xxxhdpi/image_onboarding_step3.webp
new file mode 100644
index 00000000..df0ee715
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xxxhdpi/image_onboarding_step3.webp differ
diff --git a/feature/onboarding/src/main/res/drawable-xxxhdpi/img_onboarding_step1.webp b/feature/onboarding/src/main/res/drawable-xxxhdpi/img_onboarding_step1.webp
new file mode 100644
index 00000000..074924d8
Binary files /dev/null and b/feature/onboarding/src/main/res/drawable-xxxhdpi/img_onboarding_step1.webp differ
diff --git a/feature/onboarding/src/main/res/drawable/ic_info.xml b/feature/onboarding/src/main/res/drawable/ic_info.xml
new file mode 100644
index 00000000..8c04ace4
--- /dev/null
+++ b/feature/onboarding/src/main/res/drawable/ic_info.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/feature/onboarding/src/main/res/drawable/ic_notification.xml b/feature/onboarding/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 00000000..6cdc0d16
--- /dev/null
+++ b/feature/onboarding/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml
new file mode 100644
index 00000000..04e2d097
--- /dev/null
+++ b/feature/onboarding/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+ 이전 화면
+ 건너뛰기
+ 다음
+ 시작하기
+ 온보딩 %1$d/%2$d
+
+ 감정을 적은 메모 그림
+ 마음속 이야기를 자유롭게\n털어놓아 보세요
+ 여섯 감정 캐릭터 그림
+ 6가지의 감정 친구들과\n이야기를 이어가보세요
+ 감정 메모를 비우는 휴지통 그림
+ 안좋았던 감정을\n비워보세요
+
+ 앱 사용을 위해\n접근 권한을 허용해주세요
+ 더 나은 서비스 환경을 위해\n아래의 접근 권한 허용이 필요합니다.
+ 알림 권한
+ 알림
+ 카드 및 대화 알림 시 사용
+ 비동의를 해도 앱을 이용할 수 있어요.
+ 비동의
+ 동의
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f77cf8eb..3c0d47d0 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -39,3 +39,4 @@ include(":feature:emotion")
include(":feature:setting")
include(":feature:webview")
include(":core:designsystem")
+include(":feature:onboarding")