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" }