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")