From b091d1461a7e714fe924a1cb733a3a9a04fe3d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B6=94=EC=97=B0=EC=9A=B0?= Date: Mon, 2 Feb 2026 14:25:52 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/login/datasource/LoginDataSource.kt | 7 ++- .../datasourceimpl/LoginDataSourceImpl.kt | 10 +++- .../repositoryimpl/LoginRepositoryImpl.kt | 51 ++++++++++++++++--- .../spot/login/service/LoginService.kt | 13 +++++ .../spot/token/repository/TokenRepository.kt | 7 ++- feature/mypage/build.gradle.kts | 1 + .../umcspot/spot/mypage/main/MyPageScreen.kt | 10 +++- .../spot/mypage/main/MyPageViewModel.kt | 6 +-- .../spot/signup/landing/LandingScreen.kt | 10 +++- .../spot/signup/landing/LandingState.kt | 1 + .../spot/signup/landing/LandingViewModel.kt | 34 ++++++++++--- .../signup/navigation/SignUpNavigation.kt | 5 +- 12 files changed, 127 insertions(+), 28 deletions(-) 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/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..3b96c822 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 @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.collectLatest @Composable fun LandingRoute( navigateToSignUp: () -> Unit, + navigateToHome: () -> Unit, modifier: Modifier = Modifier, viewModel: LandingViewModel = hiltViewModel(), ) { @@ -54,10 +55,15 @@ fun LandingRoute( val snackBarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.tryAutoLogin() + } + LaunchedEffect(viewModel.sideEffect) { viewModel.sideEffect.collectLatest { effect -> when (effect) { - is LandingSideEffect.NavigateToHome -> navigateToSignUp() + is LandingSideEffect.NavigateToHome -> navigateToHome() + is LandingSideEffect.NavigateToSignUp -> navigateToSignUp() is LandingSideEffect.ShowSnackBar -> { snackBarHostState.showSnackbar(effect.message) } @@ -139,4 +145,4 @@ fun LandingScreen( NaverStartButton(onClick = onNaverClick) } } -} \ No newline at end of file +} 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..26d669ef 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 @@ -6,5 +6,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..9a57c4d5 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 @@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope import com.kakao.sdk.user.UserApiClient import com.navercorp.nid.NidOAuth import com.navercorp.nid.oauth.util.NidOAuthCallback -import com.umcspot.spot.common.util.runSuspendCatching import com.umcspot.spot.model.SocialLoginType import com.umcspot.spot.token.repository.TokenRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -34,6 +33,26 @@ class LandingViewModel @Inject constructor( private val _sideEffect = MutableSharedFlow() val sideEffect = _sideEffect.asSharedFlow() + private var autoLoginChecked = false + + fun tryAutoLogin() { + if (autoLoginChecked) return + autoLoginChecked = true + + if (_uiState.value.isLoading) return + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + val result = loginRepository.refreshTokenData() + if (result.isSuccess) { + _uiState.update { it.copy(isLoading = false) } + _sideEffect.emit(LandingSideEffect.NavigateToHome) + } else { + _uiState.update { it.copy(isLoading = false) } + } + } + } + fun startSocialLogin( type: SocialLoginType, activity: Activity, @@ -91,13 +110,12 @@ class LandingViewModel @Inject constructor( private fun requestServerLogin(type: SocialLoginType, accessToken: String) = viewModelScope.launch { - runSuspendCatching { - loginRepository.finishSocialLogin(type = type, accessToken = accessToken) - }.onSuccess { + val result = loginRepository.finishSocialLogin(type = type, accessToken = accessToken) + if (result.isSuccess) { _uiState.update { it.copy(isLoading = false) } - _sideEffect.emit(LandingSideEffect.NavigateToHome) - }.onFailure { e -> - handleLoginError("서버 로그인 실패", e) + _sideEffect.emit(LandingSideEffect.NavigateToSignUp) + } else { + handleLoginError("서버 로그인 실패", result.exceptionOrNull()) } } @@ -108,4 +126,4 @@ class LandingViewModel @Inject constructor( _uiState.update { it.copy(isLoading = false) } _sideEffect.emit(LandingSideEffect.ShowSnackBar(errorMessage)) } -} \ No newline at end of file +} 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..d28e2c14 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 @@ -37,7 +37,8 @@ fun NavGraphBuilder.signupGraph( ) { composable { LandingRoute( - navigateToSignUp = navigateToSignUp + navigateToSignUp = navigateToSignUp, + navigateToHome = navigateToHome ) } composable { @@ -70,4 +71,4 @@ data object SignUp : Route data object CheckList : Route @Serializable -data object Saving : Route \ No newline at end of file +data object Saving : Route From c40c185bf1b98ace288cfe8e938b1203569adcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B6=94=EC=97=B0=EC=9A=B0?= Date: Mon, 2 Feb 2026 15:43:31 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat=20:=20api=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20accessToken=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=20refresh=EB=A1=9C=20=EA=B0=B1=EC=8B=A0=20=ED=9B=84?= =?UTF-8?q?=20=EB=8B=A4=EC=8B=9C=20=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/network/TokenAuthenticator.kt | 117 ++++++++++++++++++ .../umcspot/spot/network/di/NetworkModule.kt | 43 ++++++- .../com/umcspot/spot/network/di/Qualifier.kt | 6 +- .../network/model/TokenRefreshResponse.kt | 14 +++ .../network/service/TokenRefreshService.kt | 15 ++- 5 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 core/network/src/main/java/com/umcspot/spot/network/TokenAuthenticator.kt create mode 100644 core/network/src/main/java/com/umcspot/spot/network/model/TokenRefreshResponse.kt 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 From e84805562649eeaa769f043ee2af2bf7fd5948a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B6=94=EC=97=B0=EC=9A=B0?= Date: Thu, 12 Feb 2026 09:33:04 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat=20:=20landingScreen=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=5F1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/signup/landing/LandingScreen.kt | 34 +++++++++---------- .../spot/signup/landing/LandingState.kt | 2 +- .../spot/signup/landing/LandingViewModel.kt | 18 ++++------ 3 files changed, 23 insertions(+), 31 deletions(-) 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 3b96c822..1862029a 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 @@ -21,7 +21,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -71,37 +73,33 @@ fun LandingRoute( } } - Scaffold( - modifier = modifier, - containerColor = SpotTheme.colors.white, - contentWindowInsets = WindowInsets.systemBars, - snackbarHost = { SnackbarHost(hostState = snackBarHostState) } - ) { innerPadding -> - LandingScreen( - contentPadding = innerPadding, - isLoading = uiState.isLoading, - onKakaoClick = { - if (!uiState.isLoading) { + if(!uiState.successAutoLogin) { + Scaffold( + modifier = modifier, + containerColor = SpotTheme.colors.white, + contentWindowInsets = WindowInsets.systemBars, + snackbarHost = { SnackbarHost(hostState = snackBarHostState) } + ) { innerPadding -> + LandingScreen( + contentPadding = innerPadding, + onKakaoClick = { activity?.let { act -> viewModel.startSocialLogin(SocialLoginType.KAKAO, act) } - } - }, - onNaverClick = { - if (!uiState.isLoading) { + }, + onNaverClick = { activity?.let { act -> viewModel.startSocialLogin(SocialLoginType.NAVER, act) } } - } - ) + ) + } } } @Composable fun LandingScreen( contentPadding: PaddingValues, - isLoading: Boolean, onKakaoClick: () -> Unit, onNaverClick: () -> Unit, modifier: Modifier = Modifier 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 26d669ef..3489b1af 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 @@ -1,7 +1,7 @@ package com.umcspot.spot.signup.landing data class LandingState( - val isLoading: Boolean = false, + val successAutoLogin: Boolean = false, ) sealed interface LandingSideEffect { 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 9a57c4d5..04555a26 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 @@ -33,22 +33,16 @@ class LandingViewModel @Inject constructor( private val _sideEffect = MutableSharedFlow() val sideEffect = _sideEffect.asSharedFlow() - private var autoLoginChecked = false - fun tryAutoLogin() { - if (autoLoginChecked) return - autoLoginChecked = true - - if (_uiState.value.isLoading) return - _uiState.update { it.copy(isLoading = true) } + _uiState.update { it.copy(successAutoLogin = true) } viewModelScope.launch { val result = loginRepository.refreshTokenData() if (result.isSuccess) { - _uiState.update { it.copy(isLoading = false) } _sideEffect.emit(LandingSideEffect.NavigateToHome) + _uiState.update { it.copy(successAutoLogin = false) } } else { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.copy(successAutoLogin = false) } } } } @@ -57,7 +51,7 @@ class LandingViewModel @Inject constructor( type: SocialLoginType, activity: Activity, ) { - _uiState.update { it.copy(isLoading = true) } + _uiState.update { it.copy(successAutoLogin = true) } when (type) { SocialLoginType.KAKAO -> loginWithKakao() @@ -112,7 +106,7 @@ class LandingViewModel @Inject constructor( viewModelScope.launch { val result = loginRepository.finishSocialLogin(type = type, accessToken = accessToken) if (result.isSuccess) { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.copy(successAutoLogin = false) } _sideEffect.emit(LandingSideEffect.NavigateToSignUp) } else { handleLoginError("서버 로그인 실패", result.exceptionOrNull()) @@ -123,7 +117,7 @@ class LandingViewModel @Inject constructor( val errorMessage = if (e != null) "$msg: ${e.message}" else msg Log.e(TAG, errorMessage, e) - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.copy(successAutoLogin = false) } _sideEffect.emit(LandingSideEffect.ShowSnackBar(errorMessage)) } } From daa3bb7ae481db4fa9b8e2ea4bd722b8dec5ba45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B6=94=EC=97=B0=EC=9A=B0?= Date: Fri, 13 Feb 2026 15:16:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat=20:=20SplashScreen=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=A4=91(=20=EB=AF=B8=EC=99=84=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/signup/landing/LandingScreen.kt | 42 ++++----- .../spot/signup/landing/LandingState.kt | 3 +- .../spot/signup/landing/LandingViewModel.kt | 34 +++---- .../spot/signup/splash/SplashScreen.kt | 89 +++++++++++++++++++ .../umcspot/spot/signup/splash/SplashState.kt | 11 +++ .../spot/signup/splash/SplashViewModel.kt | 50 +++++++++++ 6 files changed, 181 insertions(+), 48 deletions(-) create mode 100644 feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashScreen.kt create mode 100644 feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashState.kt create mode 100644 feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashViewModel.kt 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 1862029a..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 @@ -21,9 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -46,7 +44,6 @@ import kotlinx.coroutines.flow.collectLatest @Composable fun LandingRoute( navigateToSignUp: () -> Unit, - navigateToHome: () -> Unit, modifier: Modifier = Modifier, viewModel: LandingViewModel = hiltViewModel(), ) { @@ -57,14 +54,9 @@ fun LandingRoute( val snackBarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { - viewModel.tryAutoLogin() - } - LaunchedEffect(viewModel.sideEffect) { viewModel.sideEffect.collectLatest { effect -> when (effect) { - is LandingSideEffect.NavigateToHome -> navigateToHome() is LandingSideEffect.NavigateToSignUp -> navigateToSignUp() is LandingSideEffect.ShowSnackBar -> { snackBarHostState.showSnackbar(effect.message) @@ -73,33 +65,37 @@ fun LandingRoute( } } - if(!uiState.successAutoLogin) { - Scaffold( - modifier = modifier, - containerColor = SpotTheme.colors.white, - contentWindowInsets = WindowInsets.systemBars, - snackbarHost = { SnackbarHost(hostState = snackBarHostState) } - ) { innerPadding -> - LandingScreen( - contentPadding = innerPadding, - onKakaoClick = { + Scaffold( + modifier = modifier, + containerColor = SpotTheme.colors.white, + contentWindowInsets = WindowInsets.systemBars, + snackbarHost = { SnackbarHost(hostState = snackBarHostState) } + ) { innerPadding -> + LandingScreen( + contentPadding = innerPadding, + isLoading = uiState.isLoading, + onKakaoClick = { + if (!uiState.isLoading) { activity?.let { act -> viewModel.startSocialLogin(SocialLoginType.KAKAO, act) } - }, - onNaverClick = { + } + }, + onNaverClick = { + if (!uiState.isLoading) { activity?.let { act -> viewModel.startSocialLogin(SocialLoginType.NAVER, act) } } - ) - } + } + ) } } @Composable fun LandingScreen( contentPadding: PaddingValues, + isLoading: Boolean, onKakaoClick: () -> Unit, onNaverClick: () -> Unit, modifier: Modifier = Modifier @@ -143,4 +139,4 @@ fun LandingScreen( NaverStartButton(onClick = onNaverClick) } } -} +} \ No newline at end of file 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 3489b1af..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 @@ -1,11 +1,10 @@ package com.umcspot.spot.signup.landing data class LandingState( - val successAutoLogin: Boolean = false, + val isLoading: Boolean = false, ) 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 04555a26..2d27871c 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope import com.kakao.sdk.user.UserApiClient import com.navercorp.nid.NidOAuth import com.navercorp.nid.oauth.util.NidOAuthCallback +import com.umcspot.spot.common.util.runSuspendCatching import com.umcspot.spot.model.SocialLoginType import com.umcspot.spot.token.repository.TokenRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,25 +34,11 @@ class LandingViewModel @Inject constructor( private val _sideEffect = MutableSharedFlow() val sideEffect = _sideEffect.asSharedFlow() - fun tryAutoLogin() { - _uiState.update { it.copy(successAutoLogin = true) } - - viewModelScope.launch { - val result = loginRepository.refreshTokenData() - if (result.isSuccess) { - _sideEffect.emit(LandingSideEffect.NavigateToHome) - _uiState.update { it.copy(successAutoLogin = false) } - } else { - _uiState.update { it.copy(successAutoLogin = false) } - } - } - } - fun startSocialLogin( type: SocialLoginType, activity: Activity, ) { - _uiState.update { it.copy(successAutoLogin = true) } + _uiState.update { it.copy(isLoading = true) } when (type) { SocialLoginType.KAKAO -> loginWithKakao() @@ -104,12 +91,13 @@ class LandingViewModel @Inject constructor( private fun requestServerLogin(type: SocialLoginType, accessToken: String) = viewModelScope.launch { - val result = loginRepository.finishSocialLogin(type = type, accessToken = accessToken) - if (result.isSuccess) { - _uiState.update { it.copy(successAutoLogin = false) } - _sideEffect.emit(LandingSideEffect.NavigateToSignUp) - } else { - handleLoginError("서버 로그인 실패", result.exceptionOrNull()) + runSuspendCatching { + loginRepository.finishSocialLogin(type = type, accessToken = accessToken) + }.onSuccess { + _uiState.update { it.copy(isLoading = false) } + _sideEffect.emit(LandingSideEffect.NavigateToHome) + }.onFailure { e -> + handleLoginError("서버 로그인 실패", e) } } @@ -117,7 +105,7 @@ class LandingViewModel @Inject constructor( val errorMessage = if (e != null) "$msg: ${e.message}" else msg Log.e(TAG, errorMessage, e) - _uiState.update { it.copy(successAutoLogin = false) } + _uiState.update { it.copy(isLoading = false) } _sideEffect.emit(LandingSideEffect.ShowSnackBar(errorMessage)) } -} +} \ No newline at end of file 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..e118601e --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashScreen.kt @@ -0,0 +1,89 @@ +package com.umcspot.spot.signup.splash + +import android.app.Activity +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.systemBars +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.button.KakaoStartButton +import com.umcspot.spot.designsystem.component.button.NaverStartButton +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.SocialLoginType +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun SplashRoute( + navigateToLanding: () -> Unit, + navigateToHome: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SplashViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val activity = context as? Activity + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val snackBarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.tryAutoLogin() + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { effect -> + when (effect) { + is LandingSideEffect.NavigateToHome -> navigateToHome() + is LandingSideEffect.NavigateToLanding -> navigateToLanding() + is LandingSideEffect.ShowSnackBar -> { + snackBarHostState.showSnackbar(effect.message) + } + } + } + } + + SplashScreen() + + +} + +@Composable +fun SplashScreen( +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + ) { + + } +} 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..c04ee2e7 --- /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 LandingState( + val successAutoLogin: Boolean = false, +) + +sealed interface LandingSideEffect { + data object NavigateToHome : LandingSideEffect + data object NavigateToLanding : 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/splash/SplashViewModel.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashViewModel.kt new file mode 100644 index 00000000..35c299e7 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/splash/SplashViewModel.kt @@ -0,0 +1,50 @@ +package com.umcspot.spot.signup.splash + +import android.app.Activity +import android.content.ContentValues.TAG +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kakao.sdk.user.UserApiClient +import com.navercorp.nid.NidOAuth +import com.navercorp.nid.oauth.util.NidOAuthCallback +import com.umcspot.spot.model.SocialLoginType +import com.umcspot.spot.token.repository.TokenRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +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.launch +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val loginRepository: TokenRepository, + @ApplicationContext private val context: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(LandingState()) + val uiState = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + + fun tryAutoLogin() { + _uiState.update { it.copy(successAutoLogin = true) } + + viewModelScope.launch { + val result = loginRepository.refreshTokenData() + if (result.isSuccess) { + _sideEffect.emit(LandingSideEffect.NavigateToHome) + _uiState.update { it.copy(successAutoLogin = false) } + } else { + _sideEffect.emit(LandingSideEffect.NavigateToLanding) + _uiState.update { it.copy(successAutoLogin = false) } + } + } + } +} From 1361a73386909ac6fb724f13e429836744e0f8f6 Mon Sep 17 00:00:00 2001 From: chooyeonwoo Date: Mon, 16 Feb 2026 21:41:41 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=9E=90=EB=8F=99=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 11 ---- .../spot/designsystem/component/splash.kt | 61 ++++++++++++++++++ .../src/main/res/raw/splash.lottie | Bin 0 -> 2480 bytes .../java/com/umcspot/spot/main/MainNavHost.kt | 1 + .../com/umcspot/spot/main/MainNavigator.kt | 13 ++-- .../spot/signup/landing/LandingViewModel.kt | 2 +- .../signup/navigation/SignUpNavigation.kt | 12 +++- .../spot/signup/splash/SplashScreen.kt | 57 ++++------------ .../umcspot/spot/signup/splash/SplashState.kt | 12 ++-- .../spot/signup/splash/SplashViewModel.kt | 34 +++++----- 10 files changed, 120 insertions(+), 83 deletions(-) create mode 100644 core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/splash.kt create mode 100644 core/designsystem/src/main/res/raw/splash.lottie 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 0000000000000000000000000000000000000000..1914d400fc731ef5af0c9844e18f5ae4312c981e GIT binary patch literal 2480 zcmZ{mXHXLg632rOKtl&ZQ6K^W300a(7egpf!4QK;FOe=F2#6v`ARxVC6r>})g(kh% zAT8wtI z^(5SFA2{E~di%(_czZl3!N8C*u=k`9lWLR#YW%jL(J)fRP%x1*PFDnHRxw}UiI$e1BG&U@prkM>*EO*8RHO~h{-V{?UE zAmBIeq`2h zTzzyd3>7X36F41sojlMeW@etYX#ST+mPxV|B}*2ri+jqE;ZC5Yey^~V2HP{rq#Due zzR?eJ&QDD73}1drs6H^WvY)cT*13olL?w`=8)Ok~?qWWdmXr6gDF&9CHd4vt3Fn6` z6=iF(7)+CnUx1z$7RXQI!VH7o`D-hCf*zAD9jl#-38FVD{xP!_aP;eV_vd|oE!7|T zg{m7F89~RMgw?j7ZOa9@_kC#r=gD`*SKc%>vT68X?d9^-H;W3pc3}Cgmr2?)AKAb( zdevj@)RySQ%)(yEl{$pEQ@=|3h+dRVj|!Va#V_NM?Rv>gmoxQEqY|A}iV7cXP?!$? zgc=`B7Z1AgB{4xE6MMaDF`7o}EoH*+>O|bU^VwnNx>p9Jt?%WrCHzjG@s8iS+>Tb> ztnRTHMdW&iT(wkAU)X$b(C6vZwdJH-TTrP!P~BL56g9&>*;~NJlH6`6%vY2oKmPm^ zP8!FpAthsTseOmI#!pb<(vb1y(r_!bKwTL~A=Qbgv%mwNe~PMNXT&Twe|h|ZJCP(N znogqZ;oc;@yvuhhU$8j)NqMLxQY}@u|4gzO?#o^T)`SHhObF4BXG9O_5^xcQ2t6nZ z1aw|ALE0QN`yzQAo!A>09owJNB?<{^b%waVrfhZx@A~#ONgN7^IC9E)NciP0ZI0`F zun@?g-+DPV74WbU85pJ?#T4RbfMk>N@rK&H3|JAy-^?6W2#(ODfa9k4gKJv&GS}Nk zx6PW@0=yp~?xoYRcx%KK`tePW)G2gX-#$-0si+}857ld)6gW7Lz=rQt9+1Lbm%oR7 zt!ni7O<;PWtx{W`d^A;~R*yYOK=kltT=fluXU?a0jo5r$|x$0I|-h;9QzemK*f6&j3$ryPX_~Xh& zl=HK=Yp_~1KoO;8^a%nc4ue~i=f9%kr&FEoZ?Vd1q!3HjjU7T$BKe&3T|1aJT3BV$ z^v{a(Guth`HqwzmlM?i~Tq{e`+#+8!htBj(@>aqIi0I5qY^X`IxBG7fGzI z1PT;aGPOp*w&Cd0hybqKYf>Av5@s%o@eJBL;V;M?*)<#K+j9fZcRGqsSxgJ(E}&Qw zGYK5b9HnKDd;zaou#f8)Fo*}~I1wp@SnFRauF;u+BO~KId<_wsxNBlZK)jZ~F+ZnJ`j-0G?G=QJlhm>$cyEcT! z3#1XnyB@-G>Tqn*l1>rE=N-H~b+0a5I<#?!dU_hbeWCMECtU?I~+mJsc0h&Ik}Zptb7PKXnwAOn4mOK8bI_{)`q z?_ggHy;Q5pEU?5=bg6dm?xFiQFPFy|8@Pmy1QE`9`n?>H45|9LF+OCMgJ zkzl_nrVNlcx{0)G+QMz`C`?x=Jk)c|&AaMJrZgJv*oaY8V*bLM&GLVW1284#EPN8+ ziL7cxLqw{>4@3Q$hr+Dl_`rw6yd6o}&CV$@gZ~*>h1Wx52G7n8tu^i0OHRzUTVFwV z6K5B47{BrgC?k=d>8T*5<4HE$DT6O6ZLx%txy(BSeqt&*{b^N;4P=om};e z`=Z%EUsp}YX<`?+mDlIOz71L1(9nf7ujz*`AYo^v-0H)Rqo3T`Islb>HJ#@WJ5$?k z9;tRcRxV({19yLzvqQ`B!y|a!zUZ>Gric?h?y5-P+z-yL&)`3&^+Ksq0a1G0K;E{6 zRnZ321nfE2ygCC1FO{WhcZ*x*Ro6&T>{vXPcwY(iFe&Hv9B1O%k1j1^B;6?Em^Pj!Ilk$IP#~2A@{4;Xu!~;$; 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 d28e2c14..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,16 +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, - navigateToHome = navigateToHome ) } composable { @@ -61,6 +68,9 @@ fun NavGraphBuilder.signupGraph( } } +@Serializable +data object Splash : Route + @Serializable data object Landing : 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 index e118601e..1bd33bc3 100644 --- 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 @@ -1,44 +1,18 @@ package com.umcspot.spot.signup.splash -import android.app.Activity -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.Box 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.systemBars -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.umcspot.spot.designsystem.R -import com.umcspot.spot.designsystem.component.button.KakaoStartButton -import com.umcspot.spot.designsystem.component.button.NaverStartButton -import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.component.Splash import com.umcspot.spot.designsystem.theme.SpotTheme -import com.umcspot.spot.model.SocialLoginType -import com.umcspot.spot.ui.extension.screenHeightDp -import com.umcspot.spot.ui.extension.screenWidthDp import kotlinx.coroutines.flow.collectLatest @Composable @@ -48,11 +22,6 @@ fun SplashRoute( modifier: Modifier = Modifier, viewModel: SplashViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val activity = context as? Activity - - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val snackBarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { @@ -62,28 +31,28 @@ fun SplashRoute( LaunchedEffect(viewModel.sideEffect) { viewModel.sideEffect.collectLatest { effect -> when (effect) { - is LandingSideEffect.NavigateToHome -> navigateToHome() - is LandingSideEffect.NavigateToLanding -> navigateToLanding() - is LandingSideEffect.ShowSnackBar -> { + is SplashSideEffect.NavigateToHome -> navigateToHome() + is SplashSideEffect.NavigateToLanding -> navigateToLanding() + is SplashSideEffect.ShowSnackBar -> { snackBarHostState.showSnackbar(effect.message) } } } } - SplashScreen() - - + SplashScreen(modifier = modifier) } @Composable fun SplashScreen( + modifier: Modifier = Modifier ) { - Column( - modifier = Modifier + Box( + modifier = modifier .fillMaxSize() - .background(SpotTheme.colors.white) + .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 index c04ee2e7..e1129726 100644 --- 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 @@ -1,11 +1,11 @@ package com.umcspot.spot.signup.splash -data class LandingState( +data class SplashState( val successAutoLogin: Boolean = false, ) -sealed interface LandingSideEffect { - data object NavigateToHome : LandingSideEffect - data object NavigateToLanding : LandingSideEffect - data class ShowSnackBar(val message: String) : LandingSideEffect -} \ No newline at end of file +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 index 35c299e7..2b6cadc7 100644 --- 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 @@ -1,48 +1,50 @@ package com.umcspot.spot.signup.splash -import android.app.Activity -import android.content.ContentValues.TAG -import android.content.Context -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kakao.sdk.user.UserApiClient -import com.navercorp.nid.NidOAuth -import com.navercorp.nid.oauth.util.NidOAuthCallback -import com.umcspot.spot.model.SocialLoginType import com.umcspot.spot.token.repository.TokenRepository import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext 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, - @ApplicationContext private val context: Context ) : ViewModel() { - private val _uiState = MutableStateFlow(LandingState()) + private val _uiState = MutableStateFlow(SplashState()) val uiState = _uiState.asStateFlow() - private val _sideEffect = MutableSharedFlow() + private val _sideEffect = MutableSharedFlow() val sideEffect = _sideEffect.asSharedFlow() + private var hasStartedAutoLogin = false + fun tryAutoLogin() { - _uiState.update { it.copy(successAutoLogin = true) } + 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(LandingSideEffect.NavigateToHome) - _uiState.update { it.copy(successAutoLogin = false) } + _sideEffect.emit(SplashSideEffect.NavigateToHome) + _uiState.update { it.copy(successAutoLogin = true) } } else { - _sideEffect.emit(LandingSideEffect.NavigateToLanding) + _sideEffect.emit(SplashSideEffect.NavigateToLanding) _uiState.update { it.copy(successAutoLogin = false) } } }