diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ccbd9dd8..cb1b5f04 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,17 +9,6 @@ plugins { android { namespace = "com.umcspot.spot" -// signingConfigs { -// getByName("debug") { -// val props = gradleLocalProperties(rootDir, providers) -// -// storeFile = file(props.getProperty("DEBUG_STORE_FILE")) -// storePassword = props.getProperty("DEBUG_STORE_PASSWORD") -// keyAlias = props.getProperty("DEBUG_KEY_ALIAS") -// keyPassword = props.getProperty("DEBUG_KEY_PASSWORD") -// } -// } - buildTypes { debug { signingConfig = signingConfigs.getByName("debug") diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/splash.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/splash.kt new file mode 100644 index 00000000..48d78988 --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/splash.kt @@ -0,0 +1,61 @@ +package com.umcspot.spot.designsystem.component + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import com.airbnb.lottie.compose.rememberLottieDynamicProperties +import com.airbnb.lottie.compose.rememberLottieDynamicProperty +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun Splash( + modifier: Modifier = Modifier, + speed: Float = 1f, + isPlaying: Boolean = true, + iterations: Int = LottieConstants.IterateForever, + strokeColor: Color? = null, + contentDescription: String? = "로딩 중" +) { + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes(R.raw.splash) + ) + + val dynamicProps = if (strokeColor != null) { + rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.STROKE_COLOR, + value = strokeColor.toArgb(), + keyPath = arrayOf("**", "Stroke 1") + ) + ) + } else { + null + } + + LottieAnimation( + composition = composition, + iterations = iterations, + isPlaying = isPlaying, + speed = speed, + dynamicProperties = dynamicProps, + modifier = modifier + .wrapContentSize() + .semantics { + if (contentDescription != null) this.contentDescription = contentDescription + } + ) +} diff --git a/core/designsystem/src/main/res/raw/splash.lottie b/core/designsystem/src/main/res/raw/splash.lottie new file mode 100644 index 00000000..1914d400 Binary files /dev/null and b/core/designsystem/src/main/res/raw/splash.lottie differ diff --git a/core/network/src/main/java/com/umcspot/spot/network/TokenAuthenticator.kt b/core/network/src/main/java/com/umcspot/spot/network/TokenAuthenticator.kt new file mode 100644 index 00000000..b9e21547 --- /dev/null +++ b/core/network/src/main/java/com/umcspot/spot/network/TokenAuthenticator.kt @@ -0,0 +1,117 @@ +package com.umcspot.spot.network + +import androidx.datastore.core.DataStore +import com.umcspot.spot.datastore.token.SpotTokenData +import com.umcspot.spot.datastore.userId.SpotUserIdData +import com.umcspot.spot.network.service.TokenRefreshService +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject + +class TokenAuthenticator @Inject constructor( + private val spotTokenDataStore: DataStore, + private val spotUserIdDataStore: DataStore, + private val tokenRefreshService: TokenRefreshService +) : Authenticator { + + private val refreshLock = Any() + private object RefreshAttempted + + override fun authenticate(route: Route?, response: Response): Request? { + val refreshAttempted = response.request.tag(RefreshAttempted::class.java) != null + if (refreshAttempted) { + clearAuthData() + return null + } + if (responseCount(response) >= 2) return null + if (response.request.url.encodedPath.endsWith("/api/auth/reissue")) return null + + val requestAccessToken = response.request.header("Authorization") + ?.removePrefix("Bearer ") + .orEmpty() + val latestAccessToken = runBlocking { + spotTokenDataStore.data.first().accessToken + } + + if (latestAccessToken.isNotBlank() && latestAccessToken != requestAccessToken) { + return response.request.newBuilder() + .header("Authorization", "Bearer $latestAccessToken") + .tag(RefreshAttempted::class.java, RefreshAttempted) + .build() + } + + synchronized(refreshLock) { + val tokenData = runBlocking { spotTokenDataStore.data.first() } + if (tokenData.accessToken.isNotBlank() && tokenData.accessToken != requestAccessToken) { + return response.request.newBuilder() + .header("Authorization", "Bearer ${tokenData.accessToken}") + .tag(RefreshAttempted::class.java, RefreshAttempted) + .build() + } + + val refreshToken = tokenData.refreshToken + if (refreshToken.isBlank()) { + clearAuthData() + return null + } + + val refreshResponse = runBlocking { + try { + tokenRefreshService.refreshTokenData(refreshToken) + } catch (e: Exception) { + clearAuthData() + null + } + } + if (refreshResponse == null) return null + + if (!refreshResponse.isSuccess) { + clearAuthData() + return null + } + + val result = refreshResponse.result + runBlocking { + spotTokenDataStore.updateData { current -> + current.copy( + accessToken = result.accessToken, + refreshToken = result.refreshToken + ) + } + spotUserIdDataStore.updateData { current -> + current.copy(userId = result.userId) + } + } + + return response.request.newBuilder() + .header("Authorization", "Bearer ${result.accessToken}") + .tag(RefreshAttempted::class.java, RefreshAttempted) + .build() + } + } + + private fun clearAuthData() { + runBlocking { + spotTokenDataStore.updateData { current -> + current.copy(accessToken = "", refreshToken = "") + } + spotUserIdDataStore.updateData { current -> + current.copy(userId = "") + } + } + } + + private fun responseCount(response: Response): Int { + var count = 1 + var prior = response.priorResponse + while (prior != null) { + count++ + prior = prior.priorResponse + } + return count + } +} diff --git a/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt b/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt index a3a56bc7..00223a0a 100644 --- a/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt @@ -4,6 +4,8 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import com.umcspot.spot.common.BuildConfigFieldProvider import com.umcspot.spot.common.WeatherConfigFieldProvider import com.umcspot.spot.network.AuthInterceptor +import com.umcspot.spot.network.TokenAuthenticator +import com.umcspot.spot.network.service.TokenRefreshService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,11 +36,23 @@ object NetworkModule { @SpotApi fun providesSpotOkHttpClient( loggingInterceptor: HttpLoggingInterceptor, - authInterceptor: AuthInterceptor + authInterceptor: AuthInterceptor, + tokenAuthenticator: TokenAuthenticator ): OkHttpClient = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .addInterceptor(authInterceptor) + .authenticator(tokenAuthenticator) + .build() + + @Provides + @Singleton + @SpotRefreshApi + fun providesSpotRefreshOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient = + OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) .build() // ---------- Weather API ---------- @@ -74,7 +88,23 @@ object NetworkModule { .addConverterFactory(converterFactory) .build() - @Provides @Singleton @WeatherApi + @Provides + @Singleton + @SpotRefreshApi + fun providesSpotRefreshRetrofit( + @SpotRefreshApi client: OkHttpClient, + converterFactory: Converter.Factory, + buildConfigProvider: BuildConfigFieldProvider + ): Retrofit = + Retrofit.Builder() + .baseUrl(buildConfigProvider.get().baseUrl) + .client(client) + .addConverterFactory(converterFactory) + .build() + + @Provides + @Singleton + @WeatherApi fun providesWeatherRetrofit( @WeatherApi weatherClient: OkHttpClient, converterFactory: Converter.Factory, @@ -85,4 +115,11 @@ object NetworkModule { .client(weatherClient) .addConverterFactory(converterFactory) .build() -} \ No newline at end of file + + @Provides + @Singleton + fun providesTokenRefreshService( + @SpotRefreshApi retrofit: Retrofit + ): TokenRefreshService = + retrofit.create(TokenRefreshService::class.java) +} diff --git a/core/network/src/main/java/com/umcspot/spot/network/di/Qualifier.kt b/core/network/src/main/java/com/umcspot/spot/network/di/Qualifier.kt index df0dcd8a..1c6d0052 100644 --- a/core/network/src/main/java/com/umcspot/spot/network/di/Qualifier.kt +++ b/core/network/src/main/java/com/umcspot/spot/network/di/Qualifier.kt @@ -8,4 +8,8 @@ annotation class SpotApi @Qualifier @Retention(AnnotationRetention.BINARY) -annotation class WeatherApi \ No newline at end of file +annotation class WeatherApi + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class SpotRefreshApi diff --git a/core/network/src/main/java/com/umcspot/spot/network/model/TokenRefreshResponse.kt b/core/network/src/main/java/com/umcspot/spot/network/model/TokenRefreshResponse.kt new file mode 100644 index 00000000..872c9c84 --- /dev/null +++ b/core/network/src/main/java/com/umcspot/spot/network/model/TokenRefreshResponse.kt @@ -0,0 +1,14 @@ +package com.umcspot.spot.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TokenRefreshResponse( + @SerialName("id") + val userId: String, + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String +) diff --git a/core/network/src/main/java/com/umcspot/spot/network/service/TokenRefreshService.kt b/core/network/src/main/java/com/umcspot/spot/network/service/TokenRefreshService.kt index 6c0421ed..be2f5312 100644 --- a/core/network/src/main/java/com/umcspot/spot/network/service/TokenRefreshService.kt +++ b/core/network/src/main/java/com/umcspot/spot/network/service/TokenRefreshService.kt @@ -1,4 +1,15 @@ -package network.service +package com.umcspot.spot.network.service -class TokenRefreshService { + +import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.network.model.TokenRefreshResponse +import retrofit2.http.Header +import retrofit2.http.POST + +interface TokenRefreshService { + + @POST("/api/auth/reissue") + suspend fun refreshTokenData( + @Header("refreshToken") refreshToken: String, + ): BaseResponse } \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt b/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt index 79494c1e..f01bd6cb 100644 --- a/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt +++ b/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt @@ -1,10 +1,13 @@ package com.umcspot.spot.login.datasource import com.umcspot.spot.login.dto.response.TokenResponseDto -import com.umcspot.spot.model.SocialLoginType import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.network.model.NullResultResponse interface LoginDataSource { - suspend fun finishSocialLogin(type : String, accessToken : String): BaseResponse + suspend fun getCallBackToken(type : String, accessToken : String): BaseResponse + suspend fun refreshTokenData(refreshToken : String) : BaseResponse + + suspend fun spotLogout(): NullResultResponse } \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt b/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt index 29541bfe..b2c75c1a 100644 --- a/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt +++ b/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt @@ -1,19 +1,25 @@ package com.umcspot.spot.login.datasourceimpl -import androidx.datastore.core.DataStore import com.umcspot.spot.login.datasource.LoginDataSource import com.umcspot.spot.login.dto.response.TokenResponseDto import com.umcspot.spot.login.service.LoginService import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.network.model.NullResultResponse import javax.inject.Inject class LoginDataSourceImpl @Inject constructor( private val loginService: LoginService ) : LoginDataSource { - override suspend fun finishSocialLogin( + override suspend fun getCallBackToken( type: String, accessToken: String ): BaseResponse = loginService.getCallBackToken(type, accessToken) + + override suspend fun refreshTokenData(refreshToken: String): BaseResponse = + loginService.refreshTokenData(refreshToken) + + override suspend fun spotLogout() : NullResultResponse = + loginService.spotLogout() } \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt b/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt index 838a7e6f..3da49b17 100644 --- a/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt +++ b/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt @@ -3,15 +3,16 @@ package com.umcspot.spot.login.repositoryimpl import androidx.datastore.core.DataStore import com.umcspot.spot.datastore.token.SpotTokenData import com.umcspot.spot.datastore.userId.SpotUserIdData +import com.umcspot.spot.login.datasource.LoginDataSource import com.umcspot.spot.login.mapper.toDomain -import com.umcspot.spot.login.service.LoginService import com.umcspot.spot.model.SocialLoginType import com.umcspot.spot.token.model.TokenResult import com.umcspot.spot.token.repository.TokenRepository +import kotlinx.coroutines.flow.first import javax.inject.Inject class LoginRepositoryImpl @Inject constructor( - private val studyService: LoginService, + private val loginDataStore: LoginDataSource, private val spotTokenDataStore: DataStore, private val spotUserIdDataStore: DataStore ) : TokenRepository { @@ -19,9 +20,9 @@ class LoginRepositoryImpl @Inject constructor( override suspend fun finishSocialLogin( type: SocialLoginType, accessToken: String - ): Result = + ): Result = runCatching { - val response = studyService.getCallBackToken(type.title, accessToken) + val response = loginDataStore.getCallBackToken(type.title, accessToken) val tokenResult: TokenResult = response.result.toDomain() spotTokenDataStore.updateData { current -> @@ -36,7 +37,45 @@ class LoginRepositoryImpl @Inject constructor( userId = tokenResult.userId ) } + } + + override suspend fun refreshTokenData( + ): Result = + runCatching { + val tokenData = spotTokenDataStore.data.first() + val refreshToken = tokenData.refreshToken + + + require(refreshToken.isNotBlank()) { "Refresh token is empty" } + + val response = loginDataStore.refreshTokenData(refreshToken) + val tokenResult: TokenResult = response.result.toDomain() + + spotTokenDataStore.updateData { current -> + current.copy( + accessToken = tokenResult.accessToken, + refreshToken = tokenResult.refreshToken + ) + } - tokenResult + spotUserIdDataStore.updateData { current -> + current.copy(userId = tokenResult.userId) + } + } + + override suspend fun spotLogout(): Result = + runCatching { + loginDataStore.spotLogout().code + + spotTokenDataStore.updateData { current -> + current.copy( + accessToken = "", + refreshToken = "" + ) + } + + spotUserIdDataStore.updateData { current -> + current.copy(userId = "") + } } -} \ No newline at end of file +} diff --git a/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt b/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt index 947e19da..caee04c3 100644 --- a/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt +++ b/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt @@ -2,7 +2,11 @@ package com.umcspot.spot.login.service import com.umcspot.spot.login.dto.response.TokenResponseDto import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.network.model.NullResultResponse import retrofit2.http.GET +import retrofit2.http.HEAD +import retrofit2.http.Header +import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -14,4 +18,13 @@ interface LoginService { @Query("accessToken") accessToken : String ) : BaseResponse + @POST("/api/auth/reissue") + suspend fun refreshTokenData( + @Header("refreshToken") refreshToken : String, + ) : BaseResponse + + @POST("/api/auth/logout") + suspend fun spotLogout( + ) : NullResultResponse + } diff --git a/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt b/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt index 7df52445..eb2ef397 100644 --- a/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt +++ b/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt @@ -1,8 +1,11 @@ package com.umcspot.spot.token.repository import com.umcspot.spot.model.SocialLoginType -import com.umcspot.spot.token.model.TokenResult interface TokenRepository { - suspend fun finishSocialLogin(type : SocialLoginType, accessToken : String) : Result + suspend fun finishSocialLogin(type : SocialLoginType, accessToken : String) : Result + + suspend fun refreshTokenData() : Result + + suspend fun spotLogout(): Result } \ No newline at end of file diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt index 61cb1fd8..83ebcd75 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt @@ -80,6 +80,7 @@ fun MainNavHost( ) { signupGraph( + navigateToLanding = { navigator.navigateToLanding(clearStackNavOptions) }, navigateToSignUp = { navigator.navigateToSignUp() }, navigateToCheckList = { navigator.navigateToCheckList() }, navigateToSaving = { navigator.navigateToSaving() }, diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt index cf3253b4..3838ad93 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt @@ -18,6 +18,7 @@ import com.umcspot.spot.feature.board.main.navigation.Board import com.umcspot.spot.feature.board.main.navigation.navigateToBoard import com.umcspot.spot.feature.board.post.content.navigation.POST_CONTENT_ROUTE import com.umcspot.spot.feature.board.post.posting.navigation.Posting +import com.umcspot.spot.home.navigation.Home import com.umcspot.spot.home.navigation.navigateToHome import com.umcspot.spot.jjim.navigation.JJim import com.umcspot.spot.jjim.navigation.navigateToJJim @@ -29,12 +30,13 @@ import com.umcspot.spot.mypage.participating.navigation.ParticipatingStudy import com.umcspot.spot.mypage.recruiting.application.navigation.STUDY_APPLICATION_ROUTE import com.umcspot.spot.mypage.recruiting.navigation.MyRecruitingStudy import com.umcspot.spot.mypage.waiting.navigation.WaitingStudy -import com.umcspot.spot.home.navigation.Home import com.umcspot.spot.signup.navigation.CheckList import com.umcspot.spot.signup.navigation.Landing import com.umcspot.spot.signup.navigation.Saving import com.umcspot.spot.signup.navigation.SignUp +import com.umcspot.spot.signup.navigation.Splash import com.umcspot.spot.signup.navigation.navigateToCheckList +import com.umcspot.spot.signup.navigation.navigateToLanding import com.umcspot.spot.signup.navigation.navigateToSaving import com.umcspot.spot.signup.navigation.navigateToSignUp import com.umcspot.spot.study.detail.navigation.StudyDetail @@ -63,7 +65,7 @@ class MainNavigator( navController .currentBackStackEntryAsState().value?.destination - val startDestination = Landing + val startDestination = Splash val currentTab: MainNavTab? @Composable get() = @@ -105,7 +107,7 @@ class MainNavigator( } @Composable - fun isInLanding(): Boolean = inAnyGraph(Landing::class, Saving::class) + fun isInLanding(): Boolean = inAnyGraph(Splash::class, Landing::class, Saving::class) @Composable fun showBackTopBar(): Boolean = inAnyGraph( @@ -168,6 +170,9 @@ class MainNavigator( navController.popBackStack() } + fun navigateToLanding(navOptions: NavOptions? = null) { + navController.navigateToLanding(navOptions) + } fun navigateToSignUp(navOptions: NavOptions? = null) { navController.navigateToSignUp(navOptions) } @@ -244,4 +249,4 @@ fun NavDestination.routeMatches(pattern: String): Boolean { return if (pattern.contains("{")) { actual.startsWith(pattern.substringBefore("{")) } else actual == pattern -} \ No newline at end of file +} diff --git a/feature/mypage/build.gradle.kts b/feature/mypage/build.gradle.kts index e5d6569c..0000e9ed 100644 --- a/feature/mypage/build.gradle.kts +++ b/feature/mypage/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(projects.domain.study) implementation(projects.domain.user) + implementation(projects.domain.token) implementation(projects.core.designsystem) implementation(projects.core.common) } \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageScreen.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageScreen.kt index 4cebc772..002e7819 100644 --- a/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageScreen.kt +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect 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 @@ -36,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.component.ProfileImage import com.umcspot.spot.designsystem.component.SpotSpinner @@ -70,6 +72,7 @@ fun MyPageScreen( viewmodel : MyPageViewModel = hiltViewModel() ) { val uiState by viewmodel.uiState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { viewmodel.load() @@ -90,7 +93,12 @@ fun MyPageScreen( onEditInterestClick = onEditInterestClick, onEditInterestLocationClick = onEditInterestLocationClick, onCancelMemberShipClick = onCancelMemberShipClick, - onLogoutClick = onLogoutClick + onLogoutClick = { + scope.launch { + viewmodel.logout() + onLogoutClick() + } + } ) } diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt index 5f624c23..5cfd13c7 100644 --- a/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.umcspot.spot.ui.state.UiState +import com.umcspot.spot.token.repository.TokenRepository import com.umcspot.spot.user.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +18,7 @@ import javax.inject.Inject @HiltViewModel class MyPageViewModel @Inject constructor( private val userRepository: UserRepository, + private val tokenRepository: TokenRepository, application: Application ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(MyPageState()) @@ -100,11 +102,9 @@ class MyPageViewModel @Inject constructor( } } catch (e: Exception) { Log.e("MyPageViewModel", "loadAppVersion error", e) -// _uiState.update { -// it.copy(appVersion = UiState.Failure(e.message ?: "앱 버전 조회 실패")) -// } } } + suspend fun logout() = tokenRepository.spotLogout() } diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingScreen.kt index ec1ef48c..005e0cfc 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingScreen.kt @@ -57,7 +57,7 @@ fun LandingRoute( LaunchedEffect(viewModel.sideEffect) { viewModel.sideEffect.collectLatest { effect -> when (effect) { - is LandingSideEffect.NavigateToHome -> navigateToSignUp() + is LandingSideEffect.NavigateToSignUp -> navigateToSignUp() is LandingSideEffect.ShowSnackBar -> { snackBarHostState.showSnackbar(effect.message) } diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingState.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingState.kt index 073c5897..7d62b6b7 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingState.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingState.kt @@ -5,6 +5,6 @@ data class LandingState( ) sealed interface LandingSideEffect { - data object NavigateToHome : LandingSideEffect + data object NavigateToSignUp : LandingSideEffect data class ShowSnackBar(val message: String) : LandingSideEffect } \ No newline at end of file diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingViewModel.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingViewModel.kt index 2d27871c..8f766a23 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingViewModel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/landing/LandingViewModel.kt @@ -95,7 +95,7 @@ class LandingViewModel @Inject constructor( loginRepository.finishSocialLogin(type = type, accessToken = accessToken) }.onSuccess { _uiState.update { it.copy(isLoading = false) } - _sideEffect.emit(LandingSideEffect.NavigateToHome) + _sideEffect.emit(LandingSideEffect.NavigateToSignUp) }.onFailure { e -> handleLoginError("서버 로그인 실패", e) } diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt index 8bdb398d..2b5389c3 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt @@ -10,6 +10,7 @@ import com.umcspot.spot.signup.landing.LandingRoute import com.umcspot.spot.navigation.Route import com.umcspot.spot.signup.SignUpRoute import com.umcspot.spot.signup.saving.SavingRoute +import com.umcspot.spot.signup.splash.SplashRoute import kotlinx.serialization.Serializable fun NavController.navigateToSignUp(navOptions: NavOptions? = null) { @@ -29,15 +30,22 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) { } fun NavGraphBuilder.signupGraph( + navigateToLanding: () -> Unit, navigateToSignUp: () -> Unit, navigateToCheckList: () -> Unit, navigateToSaving: () -> Unit, navigateToHome: () -> Unit, contentPadding: PaddingValues, ) { + composable { + SplashRoute( + navigateToHome = navigateToHome, + navigateToLanding = navigateToLanding + ) + } composable { LandingRoute( - navigateToSignUp = navigateToSignUp + navigateToSignUp = navigateToSignUp, ) } composable { @@ -60,6 +68,9 @@ fun NavGraphBuilder.signupGraph( } } +@Serializable +data object Splash : Route + @Serializable data object Landing : Route @@ -70,4 +81,4 @@ data object SignUp : Route data object CheckList : Route @Serializable -data object Saving : Route \ No newline at end of file +data object Saving : Route diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashScreen.kt new file mode 100644 index 00000000..1bd33bc3 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashScreen.kt @@ -0,0 +1,58 @@ +package com.umcspot.spot.signup.splash + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.umcspot.spot.designsystem.component.Splash +import com.umcspot.spot.designsystem.theme.SpotTheme +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun SplashRoute( + navigateToLanding: () -> Unit, + navigateToHome: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SplashViewModel = hiltViewModel(), +) { + val snackBarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.tryAutoLogin() + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { effect -> + when (effect) { + is SplashSideEffect.NavigateToHome -> navigateToHome() + is SplashSideEffect.NavigateToLanding -> navigateToLanding() + is SplashSideEffect.ShowSnackBar -> { + snackBarHostState.showSnackbar(effect.message) + } + } + } + } + + SplashScreen(modifier = modifier) +} + +@Composable +fun SplashScreen( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(SpotTheme.colors.white), + contentAlignment = Alignment.Center + ) { + Splash(modifier = Modifier.wrapContentSize()) + } +} diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashState.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashState.kt new file mode 100644 index 00000000..e1129726 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashState.kt @@ -0,0 +1,11 @@ +package com.umcspot.spot.signup.splash + +data class SplashState( + val successAutoLogin: Boolean = false, +) + +sealed interface SplashSideEffect { + data object NavigateToHome : SplashSideEffect + data object NavigateToLanding : SplashSideEffect + data class ShowSnackBar(val message: String) : SplashSideEffect +} diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashViewModel.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashViewModel.kt new file mode 100644 index 00000000..2b6cadc7 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashViewModel.kt @@ -0,0 +1,52 @@ +package com.umcspot.spot.signup.splash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.token.repository.TokenRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val loginRepository: TokenRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SplashState()) + val uiState = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + + private var hasStartedAutoLogin = false + + fun tryAutoLogin() { + if (hasStartedAutoLogin) return + hasStartedAutoLogin = true + + viewModelScope.launch { + val minimumSplashDurationMs = 2000L + val startedAt = System.currentTimeMillis() + + val result = loginRepository.refreshTokenData() + + val elapsed = System.currentTimeMillis() - startedAt + val remain = minimumSplashDurationMs - elapsed + if (remain > 0) delay(remain) + + if (result.isSuccess) { + _sideEffect.emit(SplashSideEffect.NavigateToHome) + _uiState.update { it.copy(successAutoLogin = true) } + } else { + _sideEffect.emit(SplashSideEffect.NavigateToLanding) + _uiState.update { it.copy(successAutoLogin = false) } + } + } + } +}