diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..437de5c2 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "figma-desktop": { + "type": "http", + "url": "http://127.0.0.1:3845/mcp" + } + } +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f77ccb14..8f6bf557 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -59,6 +59,7 @@ kotlin { implementation(libs.compose.runtime) implementation(libs.kakao.sdk.v2.user) implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.core.splashscreen) } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/MainActivity.kt index 1508074e..9679781a 100644 --- a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/MainActivity.kt @@ -5,19 +5,25 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.chukchukhaksa.mobile.common.provider.LocalAppContext import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.chukchukhaksa.mobile.common.provider.LocalAppContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + + var isReady = false + splashScreen.setKeepOnScreenCondition { !isReady } + enableEdgeToEdge() setContent { val context = LocalContext.current CompositionLocalProvider(LocalAppContext provides context) { - App() + App(onReady = { isReady = true }) } } } diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_apple_logo.xml b/composeApp/src/commonMain/composeResources/drawable/ic_apple_logo.xml new file mode 100644 index 00000000..6a5fe463 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_apple_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_kakao_logo.xml b/composeApp/src/commonMain/composeResources/drawable/ic_kakao_logo.xml new file mode 100644 index 00000000..348bb3e8 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_kakao_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/img_landing_1.webp b/composeApp/src/commonMain/composeResources/drawable/img_landing_1.webp new file mode 100644 index 00000000..78dcf22c Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/img_landing_1.webp differ diff --git a/composeApp/src/commonMain/composeResources/drawable/img_landing_2.webp b/composeApp/src/commonMain/composeResources/drawable/img_landing_2.webp new file mode 100644 index 00000000..bcf64c86 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/img_landing_2.webp differ diff --git a/composeApp/src/commonMain/composeResources/drawable/img_landing_3.webp b/composeApp/src/commonMain/composeResources/drawable/img_landing_3.webp new file mode 100644 index 00000000..288effaa Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/img_landing_3.webp differ diff --git a/composeApp/src/commonMain/composeResources/drawable/img_landing_4.webp b/composeApp/src/commonMain/composeResources/drawable/img_landing_4.webp new file mode 100644 index 00000000..65fdd404 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/img_landing_4.webp differ diff --git a/composeApp/src/commonMain/composeResources/drawable/img_landing_5.webp b/composeApp/src/commonMain/composeResources/drawable/img_landing_5.webp new file mode 100644 index 00000000..de2d9de5 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/img_landing_5.webp differ diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt index 039c6481..273f4e04 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/App.kt @@ -27,6 +27,10 @@ import com.chukchukhaksa.mobile.common.designsystem.theme.White import com.chukchukhaksa.mobile.common.kmp.Platform.* import com.chukchukhaksa.mobile.common.kmp.getPlatform import com.chukchukhaksa.mobile.common.ui.collectWithLifecycle +import com.chukchukhaksa.mobile.domain.auth.usecase.CheckAuthStateUseCase +import com.chukchukhaksa.mobile.presentation.landing.navigation.LandingRoute +import com.chukchukhaksa.mobile.presentation.landing.navigation.landingNavGraph +import com.chukchukhaksa.mobile.presentation.timetable.navigation.TimetableRoute import com.chukchukhaksa.mobile.presentation.timetable.navigation.timetableNavGraph import dev.gitlive.firebase.Firebase import dev.gitlive.firebase.analytics.FirebaseAnalytics @@ -34,6 +38,7 @@ import dev.gitlive.firebase.analytics.analytics import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.koin.compose.KoinContext +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @Composable @@ -41,17 +46,27 @@ fun App( modifier: Modifier = Modifier, viewModel: MainViewModel = koinViewModel(), navigator: MainNavigator = rememberMainNavigator(), + onReady: () -> Unit = {}, ) { CchTheme { KoinContext { val uiState = viewModel.mviStore.uiState.collectAsState().value val uriHandler = LocalUriHandler.current + val checkAuthStateUseCase: CheckAuthStateUseCase = koinInject() + var startDestination by remember { mutableStateOf(null) } + viewModel.mviStore.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { is MainSideEffect.OpenUrl -> uriHandler.openUri(sideEffect.url) } } + LaunchedEffect(Unit) { + val isAuthenticated = checkAuthStateUseCase().getOrDefault(false) + startDestination = if (isAuthenticated) TimetableRoute.route else LandingRoute.route + onReady() + } + LaunchedEffect(key1 = Unit) { viewModel.checkNeedForceUpdate() @@ -73,38 +88,48 @@ fun App( contentWindowInsets = WindowInsets(0.dp), modifier = modifier, content = { innerPadding -> - val navGraphBuilder: NavGraphBuilder.() -> Unit = { - timetableNavGraph( - padding = innerPadding, - popBackStack = navigator::popBackStackIfNotHome, - navigateTimetableNameInput = navigator::navigateTimetableNameInput, - navigateTimetableEditor = navigator::navigateTimetableEditor, - navigateTimetableList = navigator::navigateTimetableList, - navigateOpenLecture = navigator::navigateOpenLecture, - handleException = viewModel::handleException, - onShowToast = viewModel::onShowToast, - navigateCellEditor = navigator::navigateCellEditor, - navigateSemesterSelect = navigator::navigateSemesterSelect, - navigateTimetable = navigator::navigateTimetable, - ) - } + val currentStartDestination = startDestination - when (getPlatform()) { - Android -> NavHost( - navController = navigator.navController, - startDestination = navigator.startDestination, - enterTransition = { slideInHorizontally(tween(350, easing = FastOutSlowInEasing)) { it } }, - exitTransition = { slideOutHorizontally(tween(350, easing = FastOutSlowInEasing)) { -it / 3 } }, - popEnterTransition = { slideInHorizontally(tween(350, easing = FastOutSlowInEasing)) { -it / 3 } }, - popExitTransition = { slideOutHorizontally(tween(350, easing = FastOutSlowInEasing)) { it } }, - builder = navGraphBuilder, - ) + if (currentStartDestination != null) { + val navGraphBuilder: NavGraphBuilder.() -> Unit = { + landingNavGraph( + handleException = viewModel::handleException, + onShowToast = viewModel::onShowToast, + navigateToHome = navigator::navigateFromLandingToHome, + ) - IOS -> NavHost( - navController = navigator.navController, - startDestination = navigator.startDestination, - builder = navGraphBuilder, - ) + timetableNavGraph( + padding = innerPadding, + popBackStack = navigator::popBackStackIfNotHome, + navigateTimetableNameInput = navigator::navigateTimetableNameInput, + navigateTimetableEditor = navigator::navigateTimetableEditor, + navigateTimetableList = navigator::navigateTimetableList, + navigateOpenLecture = navigator::navigateOpenLecture, + handleException = viewModel::handleException, + onShowToast = viewModel::onShowToast, + navigateCellEditor = navigator::navigateCellEditor, + navigateSemesterSelect = navigator::navigateSemesterSelect, + navigateTimetable = navigator::navigateTimetable, + ) + } + + when (getPlatform()) { + Android -> NavHost( + navController = navigator.navController, + startDestination = currentStartDestination, + enterTransition = { slideInHorizontally(tween(350, easing = FastOutSlowInEasing)) { it } }, + exitTransition = { slideOutHorizontally(tween(350, easing = FastOutSlowInEasing)) { -it / 3 } }, + popEnterTransition = { slideInHorizontally(tween(350, easing = FastOutSlowInEasing)) { -it / 3 } }, + popExitTransition = { slideOutHorizontally(tween(350, easing = FastOutSlowInEasing)) { it } }, + builder = navGraphBuilder, + ) + + IOS -> NavHost( + navController = navigator.navController, + startDestination = currentStartDestination, + builder = navGraphBuilder, + ) + } } if (uiState.showNetworkErrorDialog) { diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainNavigator.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainNavigator.kt index 54bb7769..5a3c3153 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainNavigator.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainNavigator.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import com.chukchukhaksa.mobile.presentation.landing.navigation.LandingRoute import com.chukchukhaksa.mobile.presentation.timetable.navigation.TimetableRoute import com.chukchukhaksa.mobile.presentation.timetable.navigation.argument.CellEditorArgument import com.chukchukhaksa.mobile.presentation.timetable.navigation.argument.TimetableEditorArgument @@ -49,6 +50,14 @@ class MainNavigator( navController.navigateTimetable() } + fun navigateFromLandingToHome() { + navController.navigate(TimetableRoute.route) { + popUpTo(LandingRoute.route) { + inclusive = true + } + } + } + fun popBackStackIfNotHome() { if (!isSameCurrentDestination(TimetableRoute.route)) { navController.popBackStack() diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt index 712ba004..a32400e2 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt @@ -1,7 +1,9 @@ package com.chukchukhaksa.mobile.data.auth.datasource +import com.chukchukhaksa.mobile.domain.auth.model.RefreshTokenResult import com.chukchukhaksa.mobile.domain.auth.model.SignInResult interface RemoteAuthDataSource { suspend fun signIn(idToken: String, nonce: String): SignInResult + suspend fun refreshToken(refreshToken: String): RefreshTokenResult } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt index 99543224..1c84f3a9 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt @@ -14,6 +14,16 @@ class AuthRepositoryImpl( return remoteAuthDataSource.signIn(idToken = idToken, nonce = nonce) } + override suspend fun refreshToken() { + val token = localAuthDataSource.getRefreshToken() + ?: throw IllegalStateException("No refresh token") + val result = remoteAuthDataSource.refreshToken(token) + saveTokens( + accessToken = result.accessToken, + refreshToken = result.refreshToken, + ) + } + override suspend fun saveTokens(accessToken: String, refreshToken: String) { localAuthDataSource.saveAccessToken(accessToken) localAuthDataSource.saveRefreshToken(refreshToken) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt index 348c271e..bd346a48 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt @@ -1,6 +1,7 @@ package com.chukchukhaksa.mobile.di import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase +import com.chukchukhaksa.mobile.domain.auth.usecase.CheckAuthStateUseCase import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase import com.chukchukhaksa.mobile.domain.config.usecase.CheckNeedForceUpdateUseCase import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableCellUseCase @@ -45,5 +46,6 @@ val domainModule = module { // Auth use cases factory { AppleLoginUseCase(get()) } + factory { CheckAuthStateUseCase(get()) } factory { KakaoLoginUseCase(get(), get()) } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/PresentationModules.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/PresentationModules.kt index 3b6d2847..8c1c77ad 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/PresentationModules.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/PresentationModules.kt @@ -4,6 +4,7 @@ import com.chukchukhaksa.mobile.MainViewModel import com.chukchukhaksa.mobile.presentation.timetable.timetable.component.bottomsheet.openmajor.OpenMajorBottomSheetViewModel import com.chukchukhaksa.mobile.presentation.timetable.celleditor.CellEditorViewModel import com.chukchukhaksa.mobile.presentation.timetable.openlecture.OpenLectureViewModel +import com.chukchukhaksa.mobile.presentation.landing.LandingViewModel import com.chukchukhaksa.mobile.presentation.timetable.timetable.TimetableViewModel import com.chukchukhaksa.mobile.presentation.timetable.timetablenameinput.TimetableNameInputViewModel import com.chukchukhaksa.mobile.presentation.timetable.timetableeditor.TimetableEditorViewModel @@ -13,6 +14,7 @@ import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val presentationModule = module { + viewModelOf(::LandingViewModel) viewModelOf(::TimetableViewModel) viewModelOf(::TimetableListViewModel) viewModelOf(::TimetableNameInputViewModel) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/model/RefreshTokenResult.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/model/RefreshTokenResult.kt new file mode 100644 index 00000000..1ade2786 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/model/RefreshTokenResult.kt @@ -0,0 +1,6 @@ +package com.chukchukhaksa.mobile.domain.auth.model + +data class RefreshTokenResult( + val accessToken: String, + val refreshToken: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt index ecc74e77..7bd1758b 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt @@ -4,6 +4,7 @@ import com.chukchukhaksa.mobile.domain.auth.model.SignInResult interface AuthRepository { suspend fun signIn(idToken: String, nonce: String): SignInResult + suspend fun refreshToken() suspend fun saveTokens(accessToken: String, refreshToken: String) suspend fun getAccessToken(): String? suspend fun getRefreshToken(): String? diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/CheckAuthStateUseCase.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/CheckAuthStateUseCase.kt new file mode 100644 index 00000000..6f568ed9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/CheckAuthStateUseCase.kt @@ -0,0 +1,27 @@ +package com.chukchukhaksa.mobile.domain.auth.usecase + +import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository +import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled +import com.chukchukhaksa.mobile.remote.common.ApiException +import kotlin.coroutines.cancellation.CancellationException + +class CheckAuthStateUseCase( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(): Result = runCatchingIgnoreCancelled { + val refreshToken = authRepository.getRefreshToken() + ?: return@runCatchingIgnoreCancelled false + + try { + authRepository.refreshToken() + true + } catch (e: CancellationException) { + throw e + } catch (e: ApiException) { + authRepository.clearTokens() + false + } catch (e: Exception) { + false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingContract.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingContract.kt new file mode 100644 index 00000000..34cbc2a6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingContract.kt @@ -0,0 +1,11 @@ +package com.chukchukhaksa.mobile.presentation.landing + +data class LandingState( + val isLoading: Boolean = false, +) + +sealed interface LandingSideEffect { + data object NavigateHome : LandingSideEffect + data class ShowToast(val message: String) : LandingSideEffect + data class HandleException(val throwable: Throwable) : LandingSideEffect +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingScreen.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingScreen.kt new file mode 100644 index 00000000..17313fb8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingScreen.kt @@ -0,0 +1,188 @@ +package com.chukchukhaksa.mobile.presentation.landing + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import chukchukhaksa.composeapp.generated.resources.Res +import chukchukhaksa.composeapp.generated.resources.ic_apple_logo +import chukchukhaksa.composeapp.generated.resources.ic_kakao_logo +import chukchukhaksa.composeapp.generated.resources.img_landing_1 +import chukchukhaksa.composeapp.generated.resources.img_landing_2 +import chukchukhaksa.composeapp.generated.resources.img_landing_3 +import chukchukhaksa.composeapp.generated.resources.img_landing_4 +import chukchukhaksa.composeapp.generated.resources.img_landing_5 +import com.chukchukhaksa.mobile.common.designsystem.theme.Black100 +import com.chukchukhaksa.mobile.common.designsystem.theme.CchTheme +import com.chukchukhaksa.mobile.common.designsystem.theme.White100 +import com.chukchukhaksa.mobile.common.kmp.Platform +import com.chukchukhaksa.mobile.common.kmp.getPlatform +import com.chukchukhaksa.mobile.common.provider.LocalAppContext +import com.chukchukhaksa.mobile.common.ui.cchClickable +import com.chukchukhaksa.mobile.common.ui.collectWithLifecycle +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun LandingRoute( + viewModel: LandingViewModel = koinViewModel(), + handleException: (Throwable) -> Unit, + onShowToast: (String, Dp) -> Unit, + navigateToHome: () -> Unit, +) { + val uiState by viewModel.mviStore.uiState.collectAsStateWithLifecycle() + val context = LocalAppContext.current + + viewModel.mviStore.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is LandingSideEffect.NavigateHome -> navigateToHome() + is LandingSideEffect.ShowToast -> onShowToast(sideEffect.message, 70.dp) + is LandingSideEffect.HandleException -> handleException(sideEffect.throwable) + } + } + + LandingScreen( + uiState = uiState, + onKakaoLogin = { viewModel.onKakaoLogin(context) }, + onAppleLogin = viewModel::onAppleLogin, + ) +} + +@Composable +fun LandingScreen( + uiState: LandingState = LandingState(), + onKakaoLogin: () -> Unit = {}, + onAppleLogin: () -> Unit = {}, +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + val imageModifier = Modifier.fillMaxWidth() + + listOf( + Res.drawable.img_landing_1, + Res.drawable.img_landing_2, + Res.drawable.img_landing_3, + Res.drawable.img_landing_4, + Res.drawable.img_landing_5, + ).forEach { imageRes -> + Image( + painter = painterResource(imageRes), + contentDescription = null, + modifier = imageModifier, + contentScale = ContentScale.FillWidth, + ) + } + + Spacer(modifier = Modifier.height(LOGIN_BUTTON_AREA_HEIGHT)) + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 24.dp) + .padding(bottom = 40.dp), + ) { + SocialLoginButton( + modifier = Modifier.fillMaxWidth(), + text = "3초만에 카카오톡으로 시작하기", + iconRes = Res.drawable.ic_kakao_logo, + containerColor = KakaoYellow, + contentColor = KakaoBlack, + enabled = !uiState.isLoading, + onClick = onKakaoLogin, + ) + + if (getPlatform() == Platform.IOS) { + Spacer(modifier = Modifier.height(12.dp)) + + SocialLoginButton( + modifier = Modifier.fillMaxWidth(), + text = "애플 아이디로 시작하기", + iconRes = Res.drawable.ic_apple_logo, + containerColor = Black100, + contentColor = White100, + enabled = true, + onClick = onAppleLogin, + ) + } + } + } +} + +private val LOGIN_BUTTON_AREA_HEIGHT = 160.dp + +private val KakaoYellow = Color(0xFFFEE500) +private val KakaoBlack = Color(0xD9000000) // 85% opacity + +@Composable +private fun SocialLoginButton( + modifier: Modifier = Modifier, + text: String, + iconRes: DrawableResource, + containerColor: Color, + contentColor: Color, + enabled: Boolean, + onClick: () -> Unit, +) { + val clickableModifier = if (enabled) { + Modifier.cchClickable { onClick() } + } else { + Modifier + } + + Box( + modifier = modifier + .wrapContentHeight() + .clip(RoundedCornerShape(10.dp)) + .then(clickableModifier) + .background(containerColor) + .padding(18.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + color = contentColor, + style = CchTheme.typography.bodyMdStrong, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingViewModel.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingViewModel.kt new file mode 100644 index 00000000..6656bca7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingViewModel.kt @@ -0,0 +1,38 @@ +package com.chukchukhaksa.mobile.presentation.landing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chukchukhaksa.mobile.common.ui.MviStore +import com.chukchukhaksa.mobile.common.ui.mviStore +import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase +import kotlinx.coroutines.launch + +class LandingViewModel( + private val kakaoLoginUseCase: KakaoLoginUseCase, +) : ViewModel() { + + val mviStore: MviStore = mviStore(LandingState()) + + fun onKakaoLogin(context: Any? = null) { + if (mviStore.uiState.value.isLoading) return + mviStore.setState { copy(isLoading = true) } + + viewModelScope.launch { + try { + kakaoLoginUseCase(context) + .onSuccess { + mviStore.postSideEffect(LandingSideEffect.NavigateHome) + } + .onFailure { throwable -> + mviStore.postSideEffect(LandingSideEffect.HandleException(throwable)) + } + } finally { + mviStore.setState { copy(isLoading = false) } + } + } + } + + fun onAppleLogin() { + mviStore.postSideEffect(LandingSideEffect.ShowToast("준비 중입니다")) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/navigation/LandingNavigation.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/navigation/LandingNavigation.kt new file mode 100644 index 00000000..267534bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/navigation/LandingNavigation.kt @@ -0,0 +1,24 @@ +package com.chukchukhaksa.mobile.presentation.landing.navigation + +import androidx.compose.ui.unit.Dp +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.chukchukhaksa.mobile.presentation.landing.LandingRoute + +object LandingRoute { + const val route = "landing" +} + +fun NavGraphBuilder.landingNavGraph( + handleException: (Throwable) -> Unit, + onShowToast: (String, Dp) -> Unit, + navigateToHome: () -> Unit, +) { + composable(route = LandingRoute.route) { + LandingRoute( + handleException = handleException, + onShowToast = onShowToast, + navigateToHome = navigateToHome, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt index 0ec6c688..117e3d4f 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt @@ -11,12 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler @@ -30,16 +24,9 @@ import com.chukchukhaksa.mobile.common.designsystem.component.button.CchBasicBut import com.chukchukhaksa.mobile.common.designsystem.theme.CchTheme import com.chukchukhaksa.mobile.common.designsystem.theme.Gray600 import com.chukchukhaksa.mobile.common.designsystem.theme.Purple600 -import com.chukchukhaksa.mobile.common.kmp.Platform -import com.chukchukhaksa.mobile.common.kmp.getPlatform import com.chukchukhaksa.mobile.common.ui.cchClickable -import com.chukchukhaksa.mobile.common.provider.LocalAppContext -import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase -import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview -import org.koin.compose.koinInject private const val CCHAKSA_URL = "https://www.cchaksa.com/" private const val CCHAKSA_ASK_URL = "https://heliotrope-flea-959.notion.site/24d1a2480da1805d9d9bcaf608fb629b" @@ -80,14 +67,6 @@ fun WebViewGuideScreen() { textStyle = CchTheme.typography.bodyMdStrong, onClick = { uriHandler.openUri(CCHAKSA_URL) }, ) - - if (getPlatform() == Platform.IOS) { - Spacer(modifier = Modifier.height(16.dp)) - AppleLoginButton() - } - - Spacer(modifier = Modifier.height(16.dp)) - KakaoLoginButton() } Text( @@ -104,48 +83,3 @@ fun WebViewGuideScreen() { } } } - -@Composable -private fun AppleLoginButton( - appleLoginUseCase: AppleLoginUseCase = koinInject(), -) { - val scope = rememberCoroutineScope() - var isLoading by remember { mutableStateOf(false) } - - CchBasicButton( - modifier = Modifier.padding(horizontal = 38.dp), - text = if (isLoading) "로그인 중..." else "Apple로 로그인", - enable = !isLoading, - textStyle = CchTheme.typography.bodyMdStrong, - onClick = { - scope.launch { - isLoading = true - appleLoginUseCase() - isLoading = false - } - }, - ) -} - -@Composable -private fun KakaoLoginButton( - kakaoLoginUseCase: KakaoLoginUseCase = koinInject(), -) { - val context = LocalAppContext.current - val scope = rememberCoroutineScope() - var isLoading by remember { mutableStateOf(false) } - - CchBasicButton( - modifier = Modifier.padding(horizontal = 38.dp), - text = if (isLoading) "로그인 중..." else "카카오로 로그인", - enable = !isLoading, - textStyle = CchTheme.typography.bodyMdStrong, - onClick = { - scope.launch { - isLoading = true - kakaoLoginUseCase(context) - isLoading = false - } - }, - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt index e90b26ba..2138a868 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt @@ -1,7 +1,10 @@ package com.chukchukhaksa.mobile.remote.auth import com.chukchukhaksa.mobile.data.auth.datasource.RemoteAuthDataSource +import com.chukchukhaksa.mobile.domain.auth.model.RefreshTokenResult import com.chukchukhaksa.mobile.domain.auth.model.SignInResult +import com.chukchukhaksa.mobile.remote.auth.model.RefreshRequest +import com.chukchukhaksa.mobile.remote.auth.model.RefreshResponse import com.chukchukhaksa.mobile.remote.auth.model.SignInRequest import com.chukchukhaksa.mobile.remote.auth.model.SignInResponse import com.chukchukhaksa.mobile.remote.common.ApiResponse @@ -22,6 +25,14 @@ class RemoteAuthDataSourceImpl( return response.getDataOrThrow().toSignInResult() } + + override suspend fun refreshToken(refreshToken: String): RefreshTokenResult { + val response = httpClient.post("auth/refresh") { + setBody(RefreshRequest(refreshToken = refreshToken)) + }.body>() + + return response.getDataOrThrow().toRefreshTokenResult() + } } private fun SignInResponse.toSignInResult() = SignInResult( @@ -29,3 +40,8 @@ private fun SignInResponse.toSignInResult() = SignInResult( refreshToken = refreshToken, isPortalLinked = isPortalLinked, ) + +private fun RefreshResponse.toRefreshTokenResult() = RefreshTokenResult( + accessToken = accessToken, + refreshToken = refreshToken, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/RefreshRequest.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/RefreshRequest.kt new file mode 100644 index 00000000..94c93e41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/RefreshRequest.kt @@ -0,0 +1,8 @@ +package com.chukchukhaksa.mobile.remote.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshRequest( + val refreshToken: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/RefreshResponse.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/RefreshResponse.kt new file mode 100644 index 00000000..b6a17e97 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/RefreshResponse.kt @@ -0,0 +1,9 @@ +package com.chukchukhaksa.mobile.remote.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshResponse( + val accessToken: String, + val refreshToken: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt index bf0ddc5f..b4db1e30 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt @@ -4,6 +4,7 @@ import com.chukchukhaksa.mobile.common.kmp.httpClientEngineFactory import com.chukchukhaksa.mobile.common.kmp.isDebug import io.github.aakira.napier.Napier import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -11,9 +12,11 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.observer.ResponseObserver +import io.ktor.client.plugins.plugin import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.content.TextContent import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json @@ -83,6 +86,19 @@ val httpClientModule = module { url("https://api.cchaksa.com/api/") contentType(ContentType.Application.Json) } + }.also { client -> + if (isDebug) { + client.plugin(HttpSend).intercept { request -> + val body = request.body + if (body is TextContent) { + Napier.d( + "REQUEST BODY:\n${body.text.prettyPrintJson()}", + tag = "HttpClient", + ) + } + execute(request) + } + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2cc6bef3..b4a26f9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ ktor = "3.3.1" kakao-sdk = "2.20.6" kinappbrowser = "1.0.0" ksafe = "1.6.0" +androidx-splashscreen = "1.0.1" [libraries] @@ -102,6 +103,8 @@ kinappbrowser = { group = "dev.yjyoon", name = "kinappbrowser", version.ref = "k ksafe = { group = "eu.anifantakis", name = "ksafe", version.ref = "ksafe" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-splashscreen" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" }