diff --git a/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/datasource/LocalDataSourceImpl.kt b/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/datasource/LocalDataSourceImpl.kt index a1e6a6de1..94cfb5125 100644 --- a/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/datasource/LocalDataSourceImpl.kt +++ b/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/datasource/LocalDataSourceImpl.kt @@ -155,6 +155,14 @@ class LocalDataSourceImpl @Inject constructor( return getBoolean(KEY_VOTE_FIRST_VISIT, true) } + override fun saveIsFirstOpen(isFirstOpen: Boolean) { + putBoolean(KEY_IS_FIRST_OPEN, isFirstOpen) + } + + override fun getIsFirstOpen(): Boolean { + return getBoolean(KEY_IS_FIRST_OPEN, true) + } + companion object { private const val TAG = "preferences" private const val PREF_NAME = "pic_preferences" @@ -162,5 +170,6 @@ class LocalDataSourceImpl @Inject constructor( private const val KEY_REFRESH_TOKEN = "key_refresh_token" private const val KEY_USER_NAME = "key_user_name" private const val KEY_VOTE_FIRST_VISIT = "key_vote_first_visit" + private const val KEY_IS_FIRST_OPEN = "key_is_first_open" } } diff --git a/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/di/RepositoryModule.kt b/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/di/RepositoryModule.kt index 3850186e2..3b1258109 100644 --- a/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/di/RepositoryModule.kt @@ -1,5 +1,6 @@ package com.mashup.gabbangzip.sharedalbum.data.di +import com.mashup.gabbangzip.sharedalbum.data.repository.ConfigRepositoryImpl import com.mashup.gabbangzip.sharedalbum.data.repository.EventRepositoryImpl import com.mashup.gabbangzip.sharedalbum.data.repository.FileRepositoryImpl import com.mashup.gabbangzip.sharedalbum.data.repository.GroupRepositoryImpl @@ -7,6 +8,7 @@ import com.mashup.gabbangzip.sharedalbum.data.repository.LoginRepositoryImpl import com.mashup.gabbangzip.sharedalbum.data.repository.NotificationRepositoryImpl import com.mashup.gabbangzip.sharedalbum.data.repository.UserRepositoryImpl import com.mashup.gabbangzip.sharedalbum.data.repository.VoteRepositoryImpl +import com.mashup.gabbangzip.sharedalbum.domain.repository.ConfigRepository import com.mashup.gabbangzip.sharedalbum.domain.repository.EventRepository import com.mashup.gabbangzip.sharedalbum.domain.repository.FileRepository import com.mashup.gabbangzip.sharedalbum.domain.repository.GroupRepository @@ -50,4 +52,8 @@ interface RepositoryModule { @Singleton @Binds fun bindEventRepository(eventRepository: EventRepositoryImpl): EventRepository + + @Singleton + @Binds + fun bindConfigRepository(configRepositoryImpl: ConfigRepositoryImpl): ConfigRepository } diff --git a/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/repository/ConfigRepositoryImpl.kt b/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/repository/ConfigRepositoryImpl.kt new file mode 100644 index 000000000..38ea64ef1 --- /dev/null +++ b/data/src/main/java/com/mashup/gabbangzip/sharedalbum/data/repository/ConfigRepositoryImpl.kt @@ -0,0 +1,17 @@ +package com.mashup.gabbangzip.sharedalbum.data.repository + +import com.mashup.gabbangzip.sharedalbum.domain.datasource.LocalDataSource +import com.mashup.gabbangzip.sharedalbum.domain.repository.ConfigRepository +import javax.inject.Inject + +class ConfigRepositoryImpl @Inject constructor( + private val localDataSource: LocalDataSource, +) : ConfigRepository { + override suspend fun saveIsFirstOpen(isFirstOpen: Boolean) { + localDataSource.saveIsFirstOpen(isFirstOpen) + } + + override suspend fun getIsFirstOpen(): Boolean { + return localDataSource.getIsFirstOpen() + } +} diff --git a/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/datasource/LocalDataSource.kt b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/datasource/LocalDataSource.kt index f02b1b8ab..675497972 100644 --- a/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/datasource/LocalDataSource.kt +++ b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/datasource/LocalDataSource.kt @@ -13,4 +13,6 @@ interface LocalDataSource { fun removeUserInfo() fun saveVoteFirstVisit(isFirstVisit: Boolean) fun getVoteFirstVisit(): Boolean + fun saveIsFirstOpen(isFirstOpen: Boolean) + fun getIsFirstOpen(): Boolean } diff --git a/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/repository/ConfigRepository.kt b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/repository/ConfigRepository.kt new file mode 100644 index 000000000..125aacf05 --- /dev/null +++ b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/repository/ConfigRepository.kt @@ -0,0 +1,6 @@ +package com.mashup.gabbangzip.sharedalbum.domain.repository + +interface ConfigRepository { + suspend fun saveIsFirstOpen(isFirstOpen: Boolean) + suspend fun getIsFirstOpen(): Boolean +} diff --git a/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/usecase/config/GetIsFirstOpenUseCase.kt b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/usecase/config/GetIsFirstOpenUseCase.kt new file mode 100644 index 000000000..e2a409ff6 --- /dev/null +++ b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/usecase/config/GetIsFirstOpenUseCase.kt @@ -0,0 +1,14 @@ +package com.mashup.gabbangzip.sharedalbum.domain.usecase.config + +import com.mashup.gabbangzip.sharedalbum.domain.repository.ConfigRepository +import javax.inject.Inject + +class GetIsFirstOpenUseCase @Inject constructor( + private val configRepository: ConfigRepository, +) { + suspend operator fun invoke(): Result { + return runCatching { + configRepository.getIsFirstOpen() + } + } +} diff --git a/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/usecase/config/SaveIsFirstOpenUseCase.kt b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/usecase/config/SaveIsFirstOpenUseCase.kt new file mode 100644 index 000000000..d563e5718 --- /dev/null +++ b/domain/src/main/java/com/mashup/gabbangzip/sharedalbum/domain/usecase/config/SaveIsFirstOpenUseCase.kt @@ -0,0 +1,12 @@ +package com.mashup.gabbangzip.sharedalbum.domain.usecase.config + +import com.mashup.gabbangzip.sharedalbum.domain.repository.ConfigRepository +import javax.inject.Inject + +class SaveIsFirstOpenUseCase @Inject constructor( + private val configRepository: ConfigRepository, +) { + suspend operator fun invoke(isFirstOpen: Boolean) { + configRepository.saveIsFirstOpen(isFirstOpen) + } +} diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 19d4c1109..cc6c5d2d5 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -60,6 +60,12 @@ android:screenOrientation="portrait" tools:ignore="DiscouragedApi" /> + + 1) indicatorSpacing else 0.dp), + size = circleSize, + color = if (currentPage == i) selectedColor else unselectedColor, + ) + } + } +} + +@Composable +private fun CircleUi( + modifier: Modifier = Modifier, + size: Dp, + color: Color, +) { + Box( + modifier = modifier + .size(size) + .background(color, shape = CircleShape), + ) +} + +@Preview(showBackground = true) +@Composable +private fun HorizontalDotIndicatorPreview() { + PicHorizontalDotIndicator( + circleSize = 50.dp, + selectedColor = Gray80, + unselectedColor = Gray50, + totalPage = 4, + currentPage = 2, + indicatorSpacing = 30.dp, + ) +} + +@Preview(showBackground = true) +@Composable +private fun CircleUiPreview() { + CircleUi(Modifier, 50.dp, Gray50) +} diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingActivity.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingActivity.kt new file mode 100644 index 000000000..08900da1a --- /dev/null +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingActivity.kt @@ -0,0 +1,63 @@ +package com.mashup.gabbangzip.sharedalbum.presentation.ui.onboarding + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import com.mashup.gabbangzip.sharedalbum.presentation.R +import com.mashup.gabbangzip.sharedalbum.presentation.theme.SharedAlbumTheme +import com.mashup.gabbangzip.sharedalbum.presentation.ui.login.LoginActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class OnboardingActivity : ComponentActivity() { + private val viewModel by viewModels() + private val pageResourceIdList = listOf( + R.drawable.onboarding_1, + R.drawable.onboarding_2, + R.drawable.onboarding_3, + R.drawable.onboarding_4, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge( + SystemBarStyle.light( + Color.TRANSPARENT, + Color.TRANSPARENT, + ), + ) + setContent { + SharedAlbumTheme { + Scaffold { contentPadding -> + Box(modifier = Modifier.padding(contentPadding)) { + OnboardingScreen( + pageResourceIdList = pageResourceIdList, + onClickStart = { + viewModel.saveIsNotFirstOpen() + LoginActivity.openActivity(this@OnboardingActivity) + }, + ) + } + } + } + } + } + + companion object { + fun openActivity(context: Activity) { + context.startActivity( + Intent(context, OnboardingActivity::class.java), + ) + } + } +} diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingScreen.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 000000000..3aeabaa6b --- /dev/null +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,79 @@ +package com.mashup.gabbangzip.sharedalbum.presentation.ui.onboarding + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mashup.gabbangzip.sharedalbum.presentation.R +import com.mashup.gabbangzip.sharedalbum.presentation.theme.Gray50 +import com.mashup.gabbangzip.sharedalbum.presentation.theme.Gray80 +import com.mashup.gabbangzip.sharedalbum.presentation.ui.common.PicButton +import com.mashup.gabbangzip.sharedalbum.presentation.ui.common.PicHorizontalDotIndicator +import com.mashup.gabbangzip.sharedalbum.presentation.utils.StableImage + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OnboardingScreen( + pageResourceIdList: List, + onClickStart: () -> Unit, +) { + val pagerState = rememberPagerState { pageResourceIdList.size } + val pageDescription = stringResource(id = R.string.page_description) + + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + ) { page -> + StableImage( + modifier = Modifier.fillMaxSize(), + drawableResId = pageResourceIdList[page], + contentDescription = pageDescription.format(page), + contentScale = ContentScale.FillWidth, + ) + } + Column( + modifier = Modifier + .padding(bottom = 16.dp, start = 21.dp, end = 21.dp) + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PicHorizontalDotIndicator( + circleSize = 8.dp, + selectedColor = Gray80, + unselectedColor = Gray50, + totalPage = pagerState.pageCount, + currentPage = pagerState.currentPage + 1, + indicatorSpacing = 10.dp, + ) + PicButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp), + text = stringResource(id = R.string.start), + onButtonClicked = onClickStart, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun OnboardingScreenPreview() { + OnboardingScreen( + pageResourceIdList = listOf(), + onClickStart = {}, + ) +} diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingViewModel.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 000000000..a8e9fd7e1 --- /dev/null +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,19 @@ +package com.mashup.gabbangzip.sharedalbum.presentation.ui.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mashup.gabbangzip.sharedalbum.domain.usecase.config.SaveIsFirstOpenUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val saveIsFirstOpenUseCase: SaveIsFirstOpenUseCase, +) : ViewModel() { + fun saveIsNotFirstOpen() { + viewModelScope.launch { + saveIsFirstOpenUseCase(false) + } + } +} diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashActivity.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashActivity.kt index 234e3582a..9b0658bf4 100644 --- a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashActivity.kt +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashActivity.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.mashup.gabbangzip.sharedalbum.presentation.theme.SharedAlbumTheme import com.mashup.gabbangzip.sharedalbum.presentation.ui.login.LoginActivity import com.mashup.gabbangzip.sharedalbum.presentation.ui.main.MainActivity +import com.mashup.gabbangzip.sharedalbum.presentation.ui.onboarding.OnboardingActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -55,18 +56,20 @@ class SplashActivity : ComponentActivity() { SplashScreen() } - when (state.isUserLoggedIn) { - true -> { + when { + state.isFirstOpen == true -> { + OnboardingActivity.openActivity(this) + finish() + } + state.isFirstOpen == false && state.isUserLoggedIn == true -> { MainActivity.openActivity(this) finish() } - - false -> { + state.isFirstOpen == false && state.isUserLoggedIn == false -> { LoginActivity.openActivity(this) finish() } - - else -> {} + else -> Unit // isFirstOpen, isUserLoggedIn 업데이트를 기다리기 } } } diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashUiState.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashUiState.kt index 95e9db0c5..529c044e8 100644 --- a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashUiState.kt +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashUiState.kt @@ -3,4 +3,5 @@ package com.mashup.gabbangzip.sharedalbum.presentation.ui.splash data class SplashUiState( val isLoading: Boolean = false, val isUserLoggedIn: Boolean? = null, + val isFirstOpen: Boolean? = null, ) diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashViewModel.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashViewModel.kt index a1dc2f994..f12eca1d7 100644 --- a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashViewModel.kt +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/splash/SplashViewModel.kt @@ -3,6 +3,7 @@ package com.mashup.gabbangzip.sharedalbum.presentation.ui.splash import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mashup.gabbangzip.sharedalbum.domain.usecase.CheckUserLoggedInUseCase +import com.mashup.gabbangzip.sharedalbum.domain.usecase.config.GetIsFirstOpenUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -14,10 +15,26 @@ import javax.inject.Inject @HiltViewModel class SplashViewModel @Inject constructor( private val checkUserLoggedInUseCase: CheckUserLoggedInUseCase, + private val isFirstOpenUseCase: GetIsFirstOpenUseCase, ) : ViewModel() { private val _state = MutableStateFlow(SplashUiState()) val state = _state.asStateFlow() + init { + updateIsFirstOpen() + } + + private fun updateIsFirstOpen() { + viewModelScope.launch { + isFirstOpenUseCase() + .onSuccess { + _state.update { state -> state.copy(isFirstOpen = it) } + }.onFailure { + _state.update { state -> state.copy(isFirstOpen = false) } + } + } + } + fun checkUserLoggedIn() { _state.update { state -> state.copy(isLoading = true) diff --git a/presentation/src/main/res/drawable/onboarding_1.png b/presentation/src/main/res/drawable/onboarding_1.png new file mode 100644 index 000000000..4aa918734 Binary files /dev/null and b/presentation/src/main/res/drawable/onboarding_1.png differ diff --git a/presentation/src/main/res/drawable/onboarding_2.png b/presentation/src/main/res/drawable/onboarding_2.png new file mode 100644 index 000000000..b17b79725 Binary files /dev/null and b/presentation/src/main/res/drawable/onboarding_2.png differ diff --git a/presentation/src/main/res/drawable/onboarding_3.png b/presentation/src/main/res/drawable/onboarding_3.png new file mode 100644 index 000000000..157a94bf4 Binary files /dev/null and b/presentation/src/main/res/drawable/onboarding_3.png differ diff --git a/presentation/src/main/res/drawable/onboarding_4.png b/presentation/src/main/res/drawable/onboarding_4.png new file mode 100644 index 000000000..7b52bd31f Binary files /dev/null and b/presentation/src/main/res/drawable/onboarding_4.png differ diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index b3ddeed9d..46e4cf51a 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -37,6 +37,7 @@ 초대 코드를 받으셨나요?\n코드를 입력해 바로 그룹에 들어갈 수 있어요. 초대코드 입력하기 다음 + 시작하기 그룹의 이름은 무엇인가요? 최대 10자 까지 입력 가능해요. 그룹의 키워드를 선택해 주세요 @@ -143,6 +144,7 @@ 좋아요 가이드 이미지 싫어요 가이드 이미지 그룹원들을 쿡 찔렀어요! + %d 페이지 온보딩 화면 네트워크가 연결되어 있지 않습니다 서버가 원활하지 않습니다