diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt index e4f85707..20313064 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt @@ -17,14 +17,13 @@ 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.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -34,41 +33,52 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R -import com.alarmy.near.presentation.feature.onboarding.components.BackgroundArea import com.alarmy.near.presentation.feature.onboarding.components.OnboardingButton import com.alarmy.near.presentation.feature.onboarding.components.PageIndicator import com.alarmy.near.presentation.feature.onboarding.model.OnboardingPage +import com.alarmy.near.presentation.preview.DevicePreviewParameterProvider +import com.alarmy.near.presentation.preview.component.DevicePreviewFrame +import com.alarmy.near.presentation.preview.model.DevicePreviewSpec import com.alarmy.near.presentation.ui.component.NearFrame import com.alarmy.near.presentation.ui.theme.NearTheme import kotlinx.coroutines.launch -/** - * 온보딩 화면 메인 컴포넌트 - * 5페이지로 구성된 뷰페이저 형태의 온보딩 화면 - */ @Composable -fun OnboardingScreen( +fun OnboardingRoute( onNavigateToLogin: () -> Unit, viewModel: OnboardingViewModel = hiltViewModel(), ) { - // UI 상태 관찰 - val uiState by viewModel.uiState.collectAsState() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() - // 사이드 이펙트 처리 LaunchedEffect(Unit) { viewModel.effect.collect { effect -> when (effect) { - is OnboardingEffect.NavigateToLogin -> { - onNavigateToLogin() - } + is OnboardingEffect.NavigateToLogin -> onNavigateToLogin() } } } + OnboardingScreen( + state = uiState.value, + onCompleteOnboarding = { viewModel.completeOnboarding() }, + ) +} + +/** + * 온보딩 화면 메인 컴포넌트 + * 5페이지로 구성된 뷰페이저 형태의 온보딩 화면 + */ +@Composable +fun OnboardingScreen( + state: OnboardingUiState, + onCompleteOnboarding: () -> Unit = {}, +) { // 온보딩 페이지 데이터 - remember로 성능 최적화 val pages = remember { @@ -99,61 +109,63 @@ fun OnboardingScreen( // 상태바와 네비게이션 바 높이 계산 val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + val navigationBarHeightDp = + with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } // 페이저 상태 관리 val pagerState = rememberPagerState(pageCount = { pages.size }) val scope = rememberCoroutineScope() - NearFrame { - Box( - modifier = Modifier.fillMaxSize(), - ) { - BackgroundArea() + NearFrame(applySystemBarsPadding = false) { + Box(modifier = Modifier.fillMaxSize()) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(R.drawable.onboarding_bg_img), + contentDescription = null, + contentScale = ContentScale.FillBounds, + ) Column( modifier = Modifier + .fillMaxSize() .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), - horizontalAlignment = Alignment.CenterHorizontally, ) { - // 뷰페이저 - HorizontalPager( - state = pagerState, - ) { page -> - OnboardingPageContent( - page = pages[page], - modifier = Modifier.fillMaxWidth(), + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HorizontalPager( + modifier = Modifier.weight(1f), + state = pagerState, + ) { page -> + OnboardingPageContent( + page = pages[page], + modifier = Modifier.fillMaxSize(), + ) + } + PageIndicator( + pageCount = pages.size, + currentPage = pagerState.currentPage, ) } - Spacer(modifier = Modifier.size(24.dp)) - - // 페이지 인디케이터 - PageIndicator( - pageCount = pages.size, - currentPage = pagerState.currentPage, - ) - - Spacer(modifier = Modifier.size(14.dp)) - } - Column(modifier = Modifier.align(Alignment.BottomCenter)) { - // 다음/완료 버튼 - OnboardingButton( - currentPage = pagerState.currentPage, - totalPages = pages.size, - isLoading = uiState.isLoading, - onNextClick = { - if (pagerState.currentPage < pages.size - 1) { - scope.launch { - pagerState.animateScrollToPage(pagerState.currentPage + 1) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingButton( + currentPage = pagerState.currentPage, + totalPages = pages.size, + isLoading = state.isLoading, + onNextClick = { + if (pagerState.currentPage < pages.size - 1) { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } else { + onCompleteOnboarding() } - } else { - // 온보딩 완료 시 DataStore에 저장 - viewModel.completeOnboarding() - } - }, - ) - Spacer(modifier = Modifier.size(24.dp)) + }, + ) + Spacer(modifier = Modifier.size(24.dp)) + } } } } @@ -172,7 +184,7 @@ private fun OnboardingPageContent( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.size(22.dp)) + Spacer(modifier = Modifier.size(44.dp)) // 각 온보딩 페이지 타이틀 Text( @@ -184,16 +196,17 @@ private fun OnboardingPageContent( lineHeight = 30.sp, ), ) - Spacer(modifier = Modifier.size(16.dp)) - Image( modifier = Modifier + .weight(1f) + .fillMaxWidth() .clip(RoundedCornerShape(12.dp)), painter = painterResource(page.image), contentDescription = null, ) + Spacer(modifier = Modifier.size(16.dp)) } } @@ -235,10 +248,12 @@ private fun AnnotatedString.Builder.appendStyledText( @Preview(showBackground = true) @Composable -fun OnboardingScreenPreview() { - NearTheme { +fun OnboardingScreenPreview( + @PreviewParameter(DevicePreviewParameterProvider::class) spec: DevicePreviewSpec, +) { + DevicePreviewFrame(spec = spec) { OnboardingScreen( - onNavigateToLogin = {}, + state = OnboardingUiState(), ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt deleted file mode 100644 index 0f6f6062..00000000 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.alarmy.near.presentation.feature.onboarding.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.TileMode -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.alarmy.near.presentation.ui.theme.NearTheme - -/** - * 배경 장식 컴포넌트 - * 원형 블러 배경 장식을 제공하는 컴포넌트 - */ -@Composable -fun BackgroundArea() { - Box( - modifier = Modifier - .fillMaxSize() - .background(NearTheme.colors.WHITE_FFFFFF), - ) { - BackgroundEllipse( - modifier = Modifier.offset(x = (-78).dp), - ) - - BackgroundEllipse( - modifier = Modifier.offset(x = 201.dp, y = 155.dp), - opacity = 0.2f, - color = NearTheme.colors.PURPLE01_4E3EC7, - ) - } -} - -@Composable -fun BackgroundEllipse( - modifier: Modifier = Modifier, - color: Color = NearTheme.colors.BLUE03_58ABEC, - blurRadius: Float = 200f, - size: Dp = 251.dp, - opacity: Float = 0.3f, -) { - Box( - modifier = modifier - .size(size) - .graphicsLayer { - renderEffect = BlurEffect( - radiusX = blurRadius, - radiusY = blurRadius, - edgeTreatment = TileMode.Clamp, - ) - } - .alpha(opacity) - .background( - color = color, - shape = CircleShape - ) - ) -} - -@Preview(showBackground = true) -@Composable -fun BackgroundAreaPreview() { - NearTheme { - BackgroundArea() - } -} - -@Preview() -@Composable -fun BackgroundDecorationPreview() { - NearTheme { - BackgroundEllipse() - } -} - diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt index b8d39ef9..8bfb25b2 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt @@ -3,7 +3,7 @@ package com.alarmy.near.presentation.feature.onboarding.navigation import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.alarmy.near.presentation.feature.onboarding.OnboardingScreen +import com.alarmy.near.presentation.feature.onboarding.OnboardingRoute import kotlinx.serialization.Serializable @Serializable @@ -19,7 +19,7 @@ fun NavController.navigateToOnboarding() { // 온보딩 네비게이션 그래프 fun NavGraphBuilder.onboardingNavGraph(onNavigateToLogin: () -> Unit) { composable { - OnboardingScreen( + OnboardingRoute( onNavigateToLogin = onNavigateToLogin, ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/preview/DevicePreviewSpecs.kt b/Near/app/src/main/java/com/alarmy/near/presentation/preview/DevicePreviewSpecs.kt new file mode 100644 index 00000000..eb3cdc9b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/preview/DevicePreviewSpecs.kt @@ -0,0 +1,48 @@ +package com.alarmy.near.presentation.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.alarmy.near.presentation.preview.model.DevicePreviewSpec + +/** + * 삼성 대표 기기 해상도를 기반으로 Compose 프리뷰에서 사용할 dp 값을 제공합니다. + */ +class DevicePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + DevicePreviewSpec( + deviceName = "Galaxy S21 (FHD+)", + widthDp = 360, + heightDp = 800, + ), + DevicePreviewSpec( + deviceName = "Galaxy S23 (FHD+)", + widthDp = 360, + heightDp = 780, + ), + DevicePreviewSpec( + deviceName = "Galaxy S25 (QHD+)", + widthDp = 412, + heightDp = 915, + ), + DevicePreviewSpec( + deviceName = "Galaxy S23 Ultra (QHD+)", + widthDp = 411, + heightDp = 915, + ), + DevicePreviewSpec( + deviceName = "Galaxy Z Flip (FHD+)", + widthDp = 360, + heightDp = 860, + ), + DevicePreviewSpec( + deviceName = "Galaxy Z Fold (QXGA+)", + widthDp = 600, + heightDp = 730, + ), + DevicePreviewSpec( + deviceName = "Galaxy A60 (FHD+)", + widthDp = 360, + heightDp = 780, + ), + ) +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/preview/component/DevicePreviewFrame.kt b/Near/app/src/main/java/com/alarmy/near/presentation/preview/component/DevicePreviewFrame.kt new file mode 100644 index 00000000..64ee8669 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/preview/component/DevicePreviewFrame.kt @@ -0,0 +1,52 @@ +package com.alarmy.near.presentation.preview.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.preview.model.DevicePreviewSpec +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 지정된 기기 해상도를 기반으로 프리뷰 캔버스를 구성하고 + * 전달받은 컴포저블을 그 안에 렌더링합니다. + * 모든 화면에서 동일한 프리뷰 규격을 재사용할 때 활용할 수 있습니다. + */ +@Composable +fun DevicePreviewFrame( + spec: DevicePreviewSpec, + content: @Composable () -> Unit, +) { + NearTheme { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // 기기 정보 라벨 + Text( + text = "${spec.deviceName} (${spec.widthDp}dp x ${spec.heightDp}dp)", + style = NearTheme.typography.B2_14_MEDIUM, + modifier = Modifier.padding(vertical = 8.dp), + ) + val aspectRatio = spec.widthDp.toFloat() / spec.heightDp.toFloat() + // 프리뷰 + Surface( + modifier = + Modifier + .widthIn(max = spec.widthDp.dp) + .heightIn(max = spec.heightDp.dp) + .aspectRatio(aspectRatio), + ) { + Box(modifier = Modifier.fillMaxSize()) { + content() + } + } + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/preview/model/DevicePreviewSpec.kt b/Near/app/src/main/java/com/alarmy/near/presentation/preview/model/DevicePreviewSpec.kt new file mode 100644 index 00000000..21f1370c --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/preview/model/DevicePreviewSpec.kt @@ -0,0 +1,11 @@ +package com.alarmy.near.presentation.preview.model + +/** + * 다양한 기기 해상도를 위한 공용 프리뷰 스펙입니다. + * Compose 프리뷰에서 여러 화면 크기를 쉽게 테스트할 때 사용합니다. + */ +data class DevicePreviewSpec( + val deviceName: String, + val widthDp: Int, + val heightDp: Int, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt index 08821874..40d5e574 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt @@ -18,18 +18,25 @@ import com.alarmy.near.presentation.ui.theme.NearTheme fun NearFrame( modifier: Modifier = Modifier, backgroundColor: Color = NearTheme.colors.WHITE_FFFFFF, + applySystemBarsPadding: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + val navigationBarHeightDp = + with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + val columnModifier = + modifier + .fillMaxSize() + .background(color = backgroundColor) + .run { + when (applySystemBarsPadding) { + true -> padding(top = statusBarHeightDp, bottom = navigationBarHeightDp) + false -> this + } + } Column( - modifier = - modifier - .fillMaxSize() - .background( - color = backgroundColor, - ).padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), + modifier = columnModifier, content = content, ) } diff --git a/Near/app/src/main/res/drawable-hdpi/onboarding_bg_img.png b/Near/app/src/main/res/drawable-hdpi/onboarding_bg_img.png new file mode 100644 index 00000000..3519e524 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/onboarding_bg_img.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/onboarding_bg_img.png b/Near/app/src/main/res/drawable-mdpi/onboarding_bg_img.png new file mode 100644 index 00000000..ad86ed66 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/onboarding_bg_img.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/onboarding_bg_img.png b/Near/app/src/main/res/drawable-xhdpi/onboarding_bg_img.png new file mode 100644 index 00000000..bab74d5a Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/onboarding_bg_img.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/onboarding_bg_img.png b/Near/app/src/main/res/drawable-xxhdpi/onboarding_bg_img.png new file mode 100644 index 00000000..7bbbcc74 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/onboarding_bg_img.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/onboarding_bg_img.png b/Near/app/src/main/res/drawable-xxxhdpi/onboarding_bg_img.png new file mode 100644 index 00000000..e21fc8b6 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/onboarding_bg_img.png differ