diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 991e27ef..80ccc2c5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -18,8 +18,11 @@ xmlns:tools="http://schemas.android.com/tools"
android:theme="@style/Theme.LinkU_Android"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
+
@@ -28,6 +31,7 @@ xmlns:tools="http://schemas.android.com/tools"
+
@@ -37,6 +41,17 @@ xmlns:tools="http://schemas.android.com/tools"
android:host="open" />
+
+
+
+
+
+
+
+
@@ -47,6 +62,17 @@ xmlns:tools="http://schemas.android.com/tools"
android:path="/open"/>
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/example/linku_android/MainActivity.kt b/app/src/main/java/com/example/linku_android/MainActivity.kt
index 9b246411..9be2e3df 100644
--- a/app/src/main/java/com/example/linku_android/MainActivity.kt
+++ b/app/src/main/java/com/example/linku_android/MainActivity.kt
@@ -1,32 +1,98 @@
package com.example.linku_android
+import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.core.model.SystemBarMode
+import com.example.core.system.SystemBarController
+import com.example.linku_android.deeplink.extractSocialDeepLinkData
import dagger.hilt.android.AndroidEntryPoint
-
+import com.example.linku_android.deeplink.SocialDeepLinkBus
@AndroidEntryPoint
-class MainActivity : ComponentActivity() {
+class MainActivity : ComponentActivity(), SystemBarController {
+ private var currentSystemBarMode: SystemBarMode? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
-
intent?.data?.let { Log.d("DEEPLINK", "onCreate uri = $it") }
-
- WindowCompat.setDecorFitsSystemWindows(window, false)
+ // 앱이 꺼진 상태에서 딥링크로 실행된 경우
+ intent?.let { handleDeepLinkIntent(it) }
+ //WindowCompat.setDecorFitsSystemWindows(window, false)
//enableEdgeToEdge()
+ // 최초 실행 딥링크
setContent {
MainApp(
- viewModel = hiltViewModel()
+ viewModel = hiltViewModel(),
+
+
)
}
+ }
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ // 앱 실행 중 딥링크 들어온 경우
+ handleDeepLinkIntent(intent)
+ }
+ private fun handleDeepLinkIntent(intent: Intent) {
+ val uri = intent.data ?: return
+ when (uri.host) {
+ "auth" -> {
+ val data = extractSocialDeepLinkData(intent) ?: return
+ Log.d("DEEPLINK", "소셜 로그인 딥링크 수신: $data")
+ SocialDeepLinkBus.emit(data) // ← 다음 단계에서 만들 파일
+ }
+ }
}
-}
\ No newline at end of file
+
+
+
+
+ /**
+ * SystemBarController 구현
+ * 앱 전역 시스템 바 단일 제어 지점
+ */
+ override fun setSystemBarMode(mode: SystemBarMode) {
+ if (currentSystemBarMode == mode) return
+ currentSystemBarMode = mode
+
+ WindowCompat.setDecorFitsSystemWindows(
+ window,
+ mode == SystemBarMode.VISIBLE
+ )
+
+ val controller = WindowInsetsControllerCompat(
+ window,
+ window.decorView
+ )
+
+ if (mode == SystemBarMode.VISIBLE) {
+ controller.show(
+ WindowInsetsCompat.Type.statusBars() or
+ WindowInsetsCompat.Type.navigationBars()
+ )
+ controller.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
+ } else {
+ controller.hide(
+ WindowInsetsCompat.Type.statusBars() or
+ WindowInsetsCompat.Type.navigationBars()
+ )
+ controller.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+
+ }
+}
+
diff --git a/app/src/main/java/com/example/linku_android/MainApp.kt b/app/src/main/java/com/example/linku_android/MainApp.kt
index 6b351f8e..ea45987b 100644
--- a/app/src/main/java/com/example/linku_android/MainApp.kt
+++ b/app/src/main/java/com/example/linku_android/MainApp.kt
@@ -11,7 +11,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -47,28 +46,11 @@ import com.example.mypage.MyPageViewModel
//import com.example.mypage.MyPageScreen
import androidx.navigation.NavType
import androidx.navigation.navArgument
-import androidx.navigation.compose.navigation
import androidx.navigation.compose.currentBackStackEntryAsState
import com.example.home.HomeApp
-import com.example.curation.ui.CurationDetailScreen
-import com.example.curation.ui.CurationScreen
import com.example.login.ui.animation.AnimatedLoginScreen
-import com.example.login.ui.screen.EmailVerificationScreen
-import com.example.login.ui.terms.ServiceTermsScreen
-import com.example.login.ui.terms.PrivacyTermsScreenFixed
-import com.example.login.ui.terms.MarketingTermsScreenComposable
-import com.example.login.ui.screen.SignUpPasswordScreen
-import com.example.login.ui.screen.EmailLoginScreen
-import com.example.login.ui.screen.InterestContentScreen
-import com.example.login.ui.screen.InterestPurposeScreen
-import com.example.login.ui.screen.SignUpGenderScreen
-import com.example.login.ui.screen.SignUpNicknameScreen
-import com.example.login.ui.screen.SignUpJobScreen
-import com.example.login.ui.screen.WelcomeScreen
-import com.example.login.ui.screen.ResetPasswordScreen
-import com.example.login.viewmodel.SignUpViewModel
import java.io.File
import java.io.FileOutputStream
@@ -86,19 +68,20 @@ import com.example.login.viewmodel.LoginViewModel
import dagger.hilt.android.EntryPointAccessors
import androidx.core.net.toUri
-import com.example.curation.CurationDetailViewModel
+import com.example.core.model.auth.LoginState
+import com.example.core.model.auth.SocialLoginEvent
import com.example.linku_android.curation.curationGraph
+import com.example.linku_android.deeplink.SocialDeepLinkBus
import com.example.linku_android.deeplink.appLinkRoute
-import com.example.login.LoginApp
-import com.example.login.ui.bottom_sheet.TermsAgreementSheet
-import com.example.login.viewmodel.LoginState
+import com.example.login.navigation.LoginApp
+
@Composable
fun MainApp(
viewModel: MainViewModel,
-) {
+) {
// 앱 실행 시 실행하여 이전 계정 기록 삭제
LaunchedEffect(Unit) {
@@ -127,9 +110,51 @@ fun MainApp(
// 마이페이지에서 사용할 뷰모델
val mypageViewModel: MyPageViewModel = hiltViewModel()
+ // TEMP 토큰 임시 보관
+ var pendingSocialToken by remember { mutableStateOf(null) }
+
+ // SocialDeepLinkBus 구독 - MainActivity에서 emit한 소셜 딥링크 수신
+ LaunchedEffect(Unit) {
+ Log.d("SOCIAL_VM", "Bus 구독 시작")
+ SocialDeepLinkBus.flow.collect { data ->
+ Log.d("SOCIAL_VM", "MainApp Bus 수신: $data")
+ loginViewModel.handleSocialDeepLink(data)
+ }
+ }
+
+
+ val socialLoginEvent by loginViewModel.socialLoginEvent.collectAsStateWithLifecycle()
+4
+ LaunchedEffect(socialLoginEvent) {
+ val event = socialLoginEvent as? SocialLoginEvent.NavigateToSocialEntry ?: return@LaunchedEffect
+ pendingSocialToken = event.socialToken
+ navigator.navigate("login_root") {
+ popUpTo(0) { inclusive = true }
+ launchSingleTop = true
+ }
+ loginViewModel.consumeSocialLoginEvent()
+ }
+
+
var currentLinkuNavigationItem by remember { mutableStateOf(null) }
var showNavBar by remember { mutableStateOf(false) }
+ val loginState by loginViewModel.loginState.collectAsStateWithLifecycle()
+ LaunchedEffect(loginState) {
+ if (loginState is LoginState.Success) {
+ Log.d("SOCIAL_VM", "LoginState.Success 감지 → 홈 이동")
+ homeViewModel.refreshAfterLogin()
+ mypageViewModel.refreshUserInfo()
+ showNavBar = true
+ currentLinkuNavigationItem = LinkuNavigationItem.HOME
+ navigator.navigate(NavigationRoute.Home.route) {
+ popUpTo(0) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+
+
var saveLinkEntryTriggered by remember { mutableStateOf(false) }
// 현재 라우트 관찰
@@ -206,7 +231,7 @@ fun MainApp(
val deps = remember {
EntryPointAccessors.fromApplication(app, SplashDeps::class.java)
}
- val loginVM: LoginViewModel = hiltViewModel()
+
NavHost(
@@ -261,7 +286,7 @@ fun MainApp(
autoLoginTried = true
// refresh 있음 → 자동로그인 시도
- loginVM.tryAutoLogin(
+ loginViewModel.tryAutoLogin(
onSuccess = {
navigator.navigate(NavigationRoute.Home.route) {
popUpTo(NavigationRoute.Splash.route) { inclusive = true }
@@ -288,7 +313,9 @@ fun MainApp(
//navController = navigator,
loginViewModel = loginViewModel,
showNavBar = { showNavBar = it },
+ initialSocialToken = pendingSocialToken,
onLoginSuccess = {
+ pendingSocialToken = null
// 세선 정보가 저장 후, 홈 화면 데이터 즉시 로드
homeViewModel.refreshAfterLogin()
// 마이페이지 정보도 미리 로그(자연스럽게?)
diff --git a/app/src/main/java/com/example/linku_android/Splash.kt b/app/src/main/java/com/example/linku_android/Splash.kt
index a9263cde..6debd156 100644
--- a/app/src/main/java/com/example/linku_android/Splash.kt
+++ b/app/src/main/java/com/example/linku_android/Splash.kt
@@ -23,7 +23,9 @@ import kotlinx.coroutines.flow.first
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
+import com.example.core.model.SystemBarMode
import com.example.core.session.SessionStore
+import com.example.core.system.SystemBarController
import com.example.data.preference.AuthPreference
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
@@ -45,12 +47,17 @@ interface SplashDeps {
fun Splash(onResult: (Boolean) -> Unit) {
//바텀바 숨김
- DesignSystemBars(
- statusBarColor = Color.Transparent,
- navigationBarColor = Color.Transparent,
- darkIcons = false,
- immersive = true
- )
+ val systemBarController =
+ LocalContext.current as? SystemBarController
+ val isPreview = LocalInspectionMode.current
+ // 시스템 바 숨김 : 디자이너와 협의한 내역
+ if (!isPreview && systemBarController != null) {
+ DisposableEffect(Unit) {
+ systemBarController.setSystemBarMode(SystemBarMode.HIDDEN)
+ onDispose { }
+ }
+ }
+
val rotationAnim = remember { Animatable(0f) }
var isGlowPhase by remember { mutableStateOf(false) }
diff --git a/app/src/main/java/com/example/linku_android/deeplink/SocialDeepLinkBus.kt b/app/src/main/java/com/example/linku_android/deeplink/SocialDeepLinkBus.kt
new file mode 100644
index 00000000..fd609e69
--- /dev/null
+++ b/app/src/main/java/com/example/linku_android/deeplink/SocialDeepLinkBus.kt
@@ -0,0 +1,15 @@
+package com.example.linku_android.deeplink
+
+import com.example.core.model.auth.SocialLoginData
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+object SocialDeepLinkBus {
+ private val _flow = MutableSharedFlow(
+ extraBufferCapacity = 1,
+ replay = 1 // 늦게 구독해도 마지막 값 받을 수 있음
+ )
+ val flow: SharedFlow = _flow.asSharedFlow()
+ fun emit(data: SocialLoginData) { _flow.tryEmit(data) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/linku_android/deeplink/SocialDeepLinkHandler.kt b/app/src/main/java/com/example/linku_android/deeplink/SocialDeepLinkHandler.kt
new file mode 100644
index 00000000..67a3105e
--- /dev/null
+++ b/app/src/main/java/com/example/linku_android/deeplink/SocialDeepLinkHandler.kt
@@ -0,0 +1,28 @@
+package com.example.linku_android.deeplink
+
+import android.content.Intent
+import android.util.Log
+import com.example.core.model.auth.SocialLoginData
+
+fun extractSocialDeepLinkData(intent: Intent): SocialLoginData? {
+ val uri = intent.data ?: return null
+
+ Log.d("SOCIAL_LOGIN", "URI 전체: $uri")
+ Log.d("SOCIAL_LOGIN", "scheme: ${uri.scheme}")
+ Log.d("SOCIAL_LOGIN", "host: ${uri.host}")
+ Log.d("SOCIAL_LOGIN", "path: ${uri.path}")
+
+
+ val provider = uri.getQueryParameter("provider") ?: return null
+ val result = uri.getQueryParameter("result") ?: return null
+
+ return SocialLoginData(
+ provider = provider,
+ result = result,
+ status = uri.getQueryParameter("status"),
+ accessToken = uri.getQueryParameter("accessToken"),
+ refreshToken = uri.getQueryParameter("refreshToken"),
+ socialToken = uri.getQueryParameter("socialToken"),
+ errorCode = uri.getQueryParameter("errorCode")
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/linku_android/deeplink/SocialLoginData.kt b/app/src/main/java/com/example/linku_android/deeplink/SocialLoginData.kt
new file mode 100644
index 00000000..98d64f8c
--- /dev/null
+++ b/app/src/main/java/com/example/linku_android/deeplink/SocialLoginData.kt
@@ -0,0 +1,15 @@
+package com.example.linku_android.deeplink
+
+//02.21 -> 오늘 서원이로부터 전달함. 백 수정되는대로 헨들러 수정하겠음. 아래는 수정할 예시.
+/**
+ * data class SocialLoginData(
+ * val provider: String, // kakao / google / naver
+ * val result: String, // SUCCESS / FAIL
+ * val status: String?, // ACTIVE / TEMP (SUCCESS일 때만)
+ * val accessToken: String?, // ACTIVE: 일반 로그인용
+ * val refreshToken: String?, // ACTIVE: 자동 로그인용 (신규 추가 요청)
+ * (배제함. 보안 이슈)val userId: Long?, // ACTIVE: fetchAndSaveUserSession용 (신규 추가 요청)
+ * val socialToken: String?, // TEMP: 추가 회원가입용
+ * val errorCode: String? // FAIL일 때
+ * )
+ * */
diff --git a/core/src/main/java/com/example/core/model/SystemBarMode.kt b/core/src/main/java/com/example/core/model/SystemBarMode.kt
new file mode 100644
index 00000000..971df4b9
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/SystemBarMode.kt
@@ -0,0 +1,6 @@
+package com.example.core.model
+
+enum class SystemBarMode {
+ HIDDEN,
+ VISIBLE
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/EmailAuthState.kt b/core/src/main/java/com/example/core/model/auth/EmailAuthState.kt
new file mode 100644
index 00000000..0a2cfcae
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/EmailAuthState.kt
@@ -0,0 +1,20 @@
+package com.example.core.model.auth
+
+sealed class EmailAuthState {
+ object Idle : EmailAuthState()
+ object Sending : EmailAuthState()
+ data class SendSuccess(val message: String) : EmailAuthState()
+ data class SendError(val message: String) : EmailAuthState()
+ object Verifying : EmailAuthState()
+ object VerifySuccess : EmailAuthState()
+ data class VerifyError(val message: String) : EmailAuthState()
+}
+
+object AuthErrorMessages {
+ const val INVALID_EMAIL_FORMAT = "잘못된 이메일 형식"
+ const val EMAIL_ALREADY_EXISTS = "이미 가입된 이메일입니다."
+ const val SERVER_ERROR = "서버 오류"
+ const val VERIFY_FAILED = "인증 실패"
+ const val NETWORK_ERROR = "네트워크 오류"
+ const val INVALID_CODE = "이메일 인증 코드가 잘못 입력 되었습니다."
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/Gender.kt b/core/src/main/java/com/example/core/model/auth/Gender.kt
new file mode 100644
index 00000000..93ea3cff
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/Gender.kt
@@ -0,0 +1,12 @@
+package com.example.core.model.auth
+
+enum class Gender(val value: Int) {
+ NONE(0),
+ MALE(1),
+ FEMALE(2);
+
+ companion object {
+ fun fromValue(value: Int): Gender =
+ entries.find { it.value == value } ?: NONE
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/Interest.kt b/core/src/main/java/com/example/core/model/auth/Interest.kt
new file mode 100644
index 00000000..320b2465
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/Interest.kt
@@ -0,0 +1,29 @@
+package com.example.core.model.auth
+
+enum class Interest(
+ val displayName: String,
+ val serverKey: String
+) {
+ BUSINESS("비즈니스/마케팅", "BUSINESS"),
+ STUDY("학업/리포트 참고", "STUDY"),
+ CAREER("커리어/채용", "CAREER"),
+ PSYCHOLOGY("심리/자기계발", "PSYCHOLOGY"),
+ DESIGN("디자인/크리에이티브", "DESIGN"),
+ IT("IT/개발", "IT"),
+ WRITING("글쓰기/콘텐츠 작성", "WRITING"),
+ CURRENT_EVENTS("시사/트렌드", "CURRENT_EVENTS"),
+ STARTUP("스타트업/창업", "STARTUP"),
+ COLLECT("그냥 모아두고 싶은 글들", "COLLECT"),
+ SOCIETY("사회/문화/환경", "SOCIETY"),
+ INSIGHTS("책/인사이트 요약", "INSIGHTS");
+
+ companion object {
+ fun fromServerKey(key: String): Interest? =
+ entries.find { it.serverKey == key }
+
+ fun fromDisplayName(name: String): Interest? =
+ entries.find { it.displayName == name }
+
+ fun getAllInterests(): List = entries.toList()
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/Job.kt b/core/src/main/java/com/example/core/model/auth/Job.kt
new file mode 100644
index 00000000..22b7b2f4
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/Job.kt
@@ -0,0 +1,16 @@
+package com.example.core.model.auth
+
+enum class Job(val id: Int, val displayName: String) {
+ NONE(0, "미선택"),
+ HIGH_SCHOOL(1, "고등학생"),
+ COLLEGE(2, "대학생"),
+ WORKER(3, "직장인"),
+ SELF_EMPLOYED(4, "자영업자"),
+ FREELANCER(5, "프리랜서"),
+ JOB_SEEKER(6, "취준생");
+
+ companion object {
+ fun getAllJobs(): List = entries.filter { it != NONE }
+ fun fromId(id: Int): Job = entries.find { it.id == id } ?: NONE
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/LoginState.kt b/core/src/main/java/com/example/core/model/auth/LoginState.kt
new file mode 100644
index 00000000..bcce4fc5
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/LoginState.kt
@@ -0,0 +1,24 @@
+package com.example.core.model.auth
+
+import com.example.core.model.LoginResult
+
+sealed class LoginState {
+ object Idle : LoginState() // 초기 상태
+ object Loading : LoginState() // 로그인 진행 중
+ data class Success(val result: LoginResult) : LoginState() // 성공
+ data class Error(val errorType: LoginErrorType) : LoginState() // 실패
+}
+
+enum class LoginErrorType(val message: String) {
+ INVALID_CREDENTIALS("이메일 또는 비밀번호가 올바르지 않습니다."),
+ SERVER_ERROR("서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
+ NETWORK_ERROR("네트워크 연결을 확인해주세요."),
+ UNKNOWN_ERROR("알 수 없는 오류가 발생했습니다.")
+}
+
+sealed class AutoLoginState {
+ object Idle : AutoLoginState()
+ object Checking : AutoLoginState()
+ object Success : AutoLoginState()
+ object Failed : AutoLoginState()
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/Purpose.kt b/core/src/main/java/com/example/core/model/auth/Purpose.kt
new file mode 100644
index 00000000..66c52f9c
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/Purpose.kt
@@ -0,0 +1,23 @@
+package com.example.core.model.auth
+
+enum class Purpose(val displayName: String, val serverKey: String) {
+ SELF_DEVELOPMENT("자기계발/정보 수집", "SELF_DEVELOPMENT"),
+ SIDE_PROJECT("사이드 프로젝트/창업 준비", "SIDE_PROJECT"),
+ OTHERS("기타", "OTHERS"),
+ LATER_READING("그냥 나중에 읽고싶은 글 저장", "LATER_READING"),
+ CAREER("취업·커리어 준비", "CAREER"),
+ CREATION_REFERENCE("블로그/콘텐츠 작성 참고용", "CREATION_REFERENCE"),
+ INSIGHTS("인사이트 모으기", "INSIGHTS"),
+ WORK("업무자료 아카이빙", "WORK"),
+ STUDY("학업/리포트 정리", "STUDY");
+
+ companion object {
+ fun fromServerKey(key: String): Purpose? =
+ entries.find { it.serverKey == key }
+
+ fun fromDisplayName(name: String): Purpose? =
+ entries.find { it.displayName == name }
+
+ fun getAllPurposes(): List = entries.toList()
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/SignUpForm.kt b/core/src/main/java/com/example/core/model/auth/SignUpForm.kt
new file mode 100644
index 00000000..073e84d9
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/SignUpForm.kt
@@ -0,0 +1,15 @@
+package com.example.core.model.auth
+
+// 모든 회원가입 입력 데이터를 담는 데이터 클래스
+data class SignUpForm(
+ val email: String = "",
+ val password: String = "",
+ val nickname: String = "",
+ val gender: Gender = Gender.NONE,
+ val jobId: Int = 0,
+ val purposeList: List = emptyList(),
+ val interestList: List = emptyList(),
+ val agreeTerms: Boolean = false,
+ val agreePrivacy: Boolean = false,
+ val agreeMarketing: Boolean = false
+)
diff --git a/core/src/main/java/com/example/core/model/auth/SignUpState.kt b/core/src/main/java/com/example/core/model/auth/SignUpState.kt
new file mode 100644
index 00000000..708f63db
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/SignUpState.kt
@@ -0,0 +1,16 @@
+package com.example.core.model.auth
+
+sealed class SignUpState {
+ object Idle : SignUpState() // 초기 상태
+ object Loading : SignUpState() // 진행 중
+ object Success : SignUpState() // 성공
+ data class Error(val message: String) : SignUpState() // 실패
+}
+
+sealed class NicknameCheckState {
+ object Idle : NicknameCheckState()
+ object Checking : NicknameCheckState() // Loading 역할
+ object Available : NicknameCheckState()
+ object Duplicated : NicknameCheckState()
+ data class Error(val message: String) : NicknameCheckState()
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/SocialLoginData.kt b/core/src/main/java/com/example/core/model/auth/SocialLoginData.kt
new file mode 100644
index 00000000..3bc22cf1
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/SocialLoginData.kt
@@ -0,0 +1,24 @@
+package com.example.core.model.auth
+
+data class SocialLoginData(
+ val provider: String,//카카오, 네이버, 구글
+ val result: String,
+ val status: String?,
+ val accessToken: String?,
+ val refreshToken: String?,
+ val socialToken: String?,
+ val errorCode: String?
+)
+
+/**
+ * data class SocialLoginData(
+ * val provider: String, // kakao / google / naver
+ * val result: String, // SUCCESS / FAIL
+ * val status: String?, // ACTIVE / TEMP (SUCCESS일 때만)
+ * val accessToken: String?, // ACTIVE: 일반 로그인용
+ * val refreshToken: String?, // ACTIVE: 자동 로그인용 (신규 추가 요청)
+ * (배제함. 보안 이슈)val userId: Long?, // ACTIVE: fetchAndSaveUserSession용 (신규 추가 요청)
+ * val socialToken: String?, // TEMP: 추가 회원가입용
+ * val errorCode: String? // FAIL일 때
+ * )
+ * */
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/model/auth/SocialLoginEvent.kt b/core/src/main/java/com/example/core/model/auth/SocialLoginEvent.kt
new file mode 100644
index 00000000..c0c4cca4
--- /dev/null
+++ b/core/src/main/java/com/example/core/model/auth/SocialLoginEvent.kt
@@ -0,0 +1,8 @@
+package com.example.core.model.auth
+
+sealed class SocialLoginEvent {
+ data class NavigateToSocialEntry(
+ val socialToken: String,
+ val provider: String
+ ) : SocialLoginEvent()
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/repository/UserRepository.kt b/core/src/main/java/com/example/core/repository/UserRepository.kt
index b47c54a2..221e86c8 100644
--- a/core/src/main/java/com/example/core/repository/UserRepository.kt
+++ b/core/src/main/java/com/example/core/repository/UserRepository.kt
@@ -3,37 +3,19 @@ package com.example.core.repository
import com.example.core.model.LoginResult
import com.example.core.model.TokenReissueResult
import com.example.core.model.UserInfo
+import com.example.core.model.auth.Gender
+import com.example.core.model.auth.Interest
+import com.example.core.model.auth.Job
+import com.example.core.model.auth.Purpose
+import com.example.core.session.SessionStore
+import kotlinx.coroutines.flow.Flow
-// 목적 (Purposes)
-private val purposeMap = mapOf(
- "자기개발" to "SELF_DEVELOPMENT",
- "사이드 프로젝트/창업준비" to "SIDE_PROJECT",
- "기타" to "OTHERS",
- "그냥 나중에 읽고 싶은 글 저장" to "LATER_READING",
- "취업 커리어 준비" to "CAREER",
- "블로그/콘텐츠 작성 참고용" to "CREATION_REFERENCE",
- "인사이트 모으기" to "INSIGHTS",
- "업무자료 아카이빙" to "WORK"
-)
-
-// 관심 분야 (Interests)
-private val interestMap = mapOf(
- "비즈니스/마케팅" to "BUSINESS",
- "학업/리포트" to "STUDY",
- "커리어/채용" to "CAREER",
- "심리/자기개발" to "PSYCHOLOGY",
- "디자인/크리에이티브" to "DESIGN",
- "it 개발" to "IT",
- "글쓰기/콘텐츠 작성" to "WRITING",
- "시사/트렌드" to "CURRENT_EVENTS",
- "스타트업/창업" to "STARTUP",
- "그냥 모아두고 싶은 글들" to "COLLECT",
- "사회/문화/환경" to "SOCIETY",
- "책/인 사이트 요약" to "INSIGHTS"
-)
-
interface UserRepository {
+
+ val sessionState: Flow
+ //레포지토리가 세션 상태 플로우 제공하도록 수정함.
+
suspend fun checkNickname(nickname: String): Boolean
//suspend fun getNickname(userId: Long): String?
suspend fun login(email: String, password: String): LoginResult
@@ -62,6 +44,7 @@ interface UserRepository {
// 마이페이지 조회
suspend fun getUserInfo(userId: Long): UserInfo
+ suspend fun refreshUserInfo(userId: Long)
// 마이페이지 계정 수정
suspend fun updateUserInfo(
nickname: String,
@@ -72,4 +55,24 @@ interface UserRepository {
// 로그아웃
suspend fun logout()
+
+ //소셜 로그인 이후 사용자 정보 받음
+ suspend fun completeSocialProfile(
+ socialToken: String,
+ nickname: String,
+ gender: Gender,
+ job: Job,
+ purposes: List,
+ interests: List
+ ): Boolean
+
+ suspend fun updateUserProfile(
+ nickname: String,
+ jobId: Long,
+ jobName: String,
+ purposes: List,
+ interests: List
+ )
+
+
}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/system/SystemBarController.kt b/core/src/main/java/com/example/core/system/SystemBarController.kt
new file mode 100644
index 00000000..fb1f16d8
--- /dev/null
+++ b/core/src/main/java/com/example/core/system/SystemBarController.kt
@@ -0,0 +1,7 @@
+package com.example.core.system
+
+import com.example.core.model.SystemBarMode
+
+interface SystemBarController {
+ fun setSystemBarMode(mode: SystemBarMode)
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/example/core/util/UrlUtils.kt b/core/src/main/java/com/example/core/util/UrlUtils.kt
new file mode 100644
index 00000000..494755e5
--- /dev/null
+++ b/core/src/main/java/com/example/core/util/UrlUtils.kt
@@ -0,0 +1,5 @@
+package com.example.core.util
+
+fun ensureHttpScheme(raw: String): String =
+ if (raw.startsWith("http://") || raw.startsWith("https://")) raw
+ else "https://$raw"
\ No newline at end of file
diff --git a/data/src/main/java/com/example/data/api/UserApi.kt b/data/src/main/java/com/example/data/api/UserApi.kt
index af071ce2..a5dbaf3f 100644
--- a/data/src/main/java/com/example/data/api/UserApi.kt
+++ b/data/src/main/java/com/example/data/api/UserApi.kt
@@ -8,6 +8,7 @@ import com.example.data.api.dto.server.JoinDTO
import com.example.data.api.dto.server.JoinResultDTO
import com.example.data.api.dto.server.LoginRequestDTO
import com.example.data.api.dto.server.LoginResultDTO
+import com.example.data.api.dto.server.SocialProfileRequestDTO
import com.example.data.api.dto.server.TempPasswordRequestDTO
import com.example.data.api.dto.server.TokenPair
import com.example.data.api.dto.server.UpdateProfileDTO
@@ -48,7 +49,7 @@ interface UserApi {
): BaseResponse
// 로그인
- @POST("/api/users/login")
+ @POST("/api/v1/users/login")
suspend fun signIn(
@Body body: LoginRequestDTO
): BaseResponse
@@ -62,9 +63,9 @@ interface UserApi {
// 마이페이지 조회
- @GET("/api/users/{userId}")
+ @GET("/api/v1/users/me")
suspend fun getUserInfo(
- @Path("userId") userId: Long
+// @Path("userId") userId: Long
): BaseResponse
// 마이페이지 수정
@@ -88,4 +89,12 @@ interface UserApi {
@Query("email") email: String,
@Query("code") code: String
): BaseResponse
+
+
+ //소셜 로그인 이후 닉네임, 성별, 직업, 목적, 관심 콘텐츠만 담는 api
+ @PATCH("/api/users/social/complete")
+ suspend fun completeSocialProfile(
+ @Header("Authorization") authorization: String,
+ @Body body: SocialProfileRequestDTO
+ ): ApiResponseString
}
\ No newline at end of file
diff --git a/data/src/main/java/com/example/data/api/dto/server/SocialProfileRequestDTO.kt b/data/src/main/java/com/example/data/api/dto/server/SocialProfileRequestDTO.kt
new file mode 100644
index 00000000..8e0ba7c8
--- /dev/null
+++ b/data/src/main/java/com/example/data/api/dto/server/SocialProfileRequestDTO.kt
@@ -0,0 +1,9 @@
+package com.example.data.api.dto.server
+
+data class SocialProfileRequestDTO(
+ val nickName: String,
+ val gender: Int,
+ val jobId: Int,
+ val purposeList: List,
+ val interestList: List
+)
\ No newline at end of file
diff --git a/data/src/main/java/com/example/data/implementation/repository/UserRepositoryImpl.kt b/data/src/main/java/com/example/data/implementation/repository/UserRepositoryImpl.kt
index 5ec0011f..755081a8 100644
--- a/data/src/main/java/com/example/data/implementation/repository/UserRepositoryImpl.kt
+++ b/data/src/main/java/com/example/data/implementation/repository/UserRepositoryImpl.kt
@@ -4,6 +4,8 @@ import android.util.Log
import com.example.core.model.LoginResult
import com.example.core.model.TokenReissueResult
import com.example.core.model.UserInfo
+import com.example.core.model.auth.Interest
+import com.example.core.model.auth.Purpose
import com.example.core.repository.UserRepository
import com.example.core.session.SessionStore
import com.example.data.api.ApiError
@@ -13,18 +15,15 @@ import com.example.data.api.dto.server.JoinDTO
import com.example.data.api.dto.server.LoginRequestDTO
import com.example.data.preference.AuthPreference
import com.example.data.api.dto.server.DeleteReasonDTO
-import com.example.data.api.dto.server.UserInfoDTO
import com.example.data.api.withAuth
-import com.example.data.api.withAuthHeaderRaw
-import com.example.data.api.dto.server.TempPasswordRequestDTO
import com.example.data.api.dto.server.UpdateProfileDTO
import com.example.data.api.withAuthRaw
import com.example.data.api.withErrorHandling
import com.example.data.api.withErrorHandlingRaw
-import retrofit2.HttpException
-import java.time.OffsetDateTime
-import java.time.format.DateTimeParseException
import javax.inject.Inject
+import com.example.data.mapper.SocialProfileMapper
+import com.example.core.model.auth.*
+import kotlinx.coroutines.flow.Flow
class UserRepositoryImpl @Inject constructor(
private val userApi: UserApi,
@@ -33,38 +32,8 @@ class UserRepositoryImpl @Inject constructor(
private val sessionStore: SessionStore
) : UserRepository {
-
- // ENUM 매핑s
- private val purposeMap = mapOf(
- "자기계발/정보 수집" to "SELF_DEVELOPMENT",
- "사이드 프로젝트/창업 준비" to "SIDE_PROJECT",
- "기타" to "OTHERS",
- "그냥 나중에 읽고싶은 글 저장" to "LATER_READING",
- "취업·커리어 준비" to "CAREER",
- "블로그/콘텐츠 작성 참고용" to "CREATION_REFERENCE",
- "인사이트 모으기" to "INSIGHTS",
- "업무자료 아카이빙" to "WORK",
- "학업/리포트 정리" to "STUDY"
- )
-
- private val interestMap = mapOf(
- "비즈니스/마케팅" to "BUSINESS",
- "학업/리포트 참고" to "STUDY",
- "커리어/채용" to "CAREER",
- "심리/자기계발" to "PSYCHOLOGY",
- "디자인/크리에이티브" to "DESIGN",
- "IT/개발" to "IT",
- "글쓰기/콘텐츠 작성" to "WRITING",
- "시사/트렌드" to "CURRENT_EVENTS",
- "스타트업/창업" to "STARTUP",
- "그냥 모아두고 싶은 글들" to "COLLECT",
- "사회/문화/환경" to "SOCIETY",
- "책/인사이트 요약" to "INSIGHTS"
- )
-
- private val reversePurposeMap = purposeMap.entries.associate { it.value to it.key }
- private val reverseInterestMap = interestMap.entries.associate { it.value to it.key }
-
+ override val sessionState: Flow
+ get() = sessionStore.session //레포지토리가 세션 Flow를 책임질 수 있도록 수정함.
// checkNickname - ApiResponseString 반환하므로 withErrorHandlingRaw 사용
override suspend fun checkNickname(nickname: String): Boolean {
@@ -114,9 +83,17 @@ class UserRepositoryImpl @Inject constructor(
purposeList: List,
interestList: List
): Boolean {
- // UI에서 넘어온 한글 리스트를 ENUM 리스트로 변환
- val safePurposeList = purposeList.map { purposeMap[it] ?: it }
- val safeInterestList = interestList.map { interestMap[it] ?: it }
+ // enum 사용: 한글 displayName → serverKey 변환
+ val safePurposeList = purposeList.mapNotNull { displayName ->
+ Purpose.fromDisplayName(displayName)?.serverKey.also {
+ if (it == null) Log.w(TAG, "알 수 없는 Purpose: $displayName")
+ }
+ }
+ val safeInterestList = interestList.mapNotNull { displayName ->
+ Interest.fromDisplayName(displayName)?.serverKey.also {
+ if (it == null) Log.w(TAG, "알 수 없는 Interest: $displayName")
+ }
+ }
require(safePurposeList.isNotEmpty()) { "purposeList는 비어 있을 수 없습니다." }
require(safeInterestList.isNotEmpty()) { "interestList는 비어 있을 수 없습니다." }
@@ -197,13 +174,14 @@ class UserRepositoryImpl @Inject constructor(
}
+
// 인증 필요 API (withAuth)
override suspend fun getUserInfo(userId: Long): UserInfo {
//val fullToken = authPreference.accessToken
//Log.d(TAG, "📍 Full AccessToken: $fullToken")
val dto = serverApi.withAuth(authPreference) {
- getUserInfo(userId)
+ getUserInfo(/*userId*/)
}
// 📍 서버 원본 데이터 확인
@@ -211,9 +189,16 @@ class UserRepositoryImpl @Inject constructor(
Log.d(TAG, "📍 [서버 원본] interests: ${dto.interests}")
- // 서버에서 온 ENUM(CAREER 등)을 UI용 한글("취업 커리어 준비")로 변환
- val displayPurposes = dto.purposes.map { reversePurposeMap[it] ?: it }
- val displayInterests = dto.interests.map { reverseInterestMap[it] ?: it }
+ val displayPurposes = dto.purposes.mapNotNull { serverKey ->
+ Purpose.fromServerKey(serverKey)?.displayName ?: serverKey.also {
+ Log.w(TAG, "알 수 없는 Purpose serverKey: $serverKey")
+ }
+ }
+ val displayInterests = dto.interests.mapNotNull { serverKey ->
+ Interest.fromServerKey(serverKey)?.displayName ?: serverKey.also {
+ Log.w(TAG, "알 수 없는 Interest serverKey: $serverKey")
+ }
+ }
// 📍 변환 후 데이터 확인
Log.d(TAG, "📍 [변환 후] purposes: $displayPurposes")
@@ -259,8 +244,12 @@ class UserRepositoryImpl @Inject constructor(
interests: List
): Boolean {
// 수정 시에도 한글 -> ENUM 변환 후 전송
- val mappedPurposes = purposes.map { purposeMap[it] ?: it }
- val mappedInterests = interests.map { interestMap[it] ?: it }
+ val mappedPurposes = purposes.mapNotNull { displayName ->
+ Purpose.fromDisplayName(displayName)?.serverKey
+ }
+ val mappedInterests = interests.mapNotNull { displayName ->
+ Interest.fromDisplayName(displayName)?.serverKey
+ }
val dto = UpdateProfileDTO(
nickname = nickname,
@@ -293,7 +282,7 @@ class UserRepositoryImpl @Inject constructor(
override suspend fun getNickname(userId: Long): String? {
return try {
val dto = serverApi.withAuth(authPreference) {
- getUserInfo(userId)
+ getUserInfo(/*userId*/)
}
val nick = dto.nickName
Log.d(TAG, "닉네임=$nick")
@@ -304,9 +293,11 @@ class UserRepositoryImpl @Inject constructor(
}
}
- // logout? - TODO : 지현아... 세션으로 마이페이지 해야할 듯...
+ // logout
override suspend fun logout() {
- clearAuthData()
+ authPreference.clear()
+ sessionStore.clear()
+ //clearAuthData()
Log.d(TAG, "로그아웃 완료")
}
private suspend fun clearAuthData() {
@@ -321,4 +312,67 @@ class UserRepositoryImpl @Inject constructor(
companion object {
private const val TAG = "UserRepository"
}
+
+ // 소셜로 회원가입 이후 프로필 정보 입력 받는 api
+ override suspend fun completeSocialProfile(
+ socialToken: String,
+ nickname: String,
+ gender: Gender,
+ job: Job,
+ purposes: List,
+ interests: List
+ ): Boolean {
+
+ val request = SocialProfileMapper.toRequest(
+ nickName = nickname,
+ gender = gender,
+ job = job,
+ purposes = purposes,
+ interests = interests
+ )
+
+ return try {
+ userApi.completeSocialProfile(
+ authorization = "Bearer $socialToken",
+ body = request
+ )
+ Log.d(TAG, "[소셜 프로필 완료] 성공")
+ true
+ } catch (e: ApiError) {
+ // data 레이어 예외를 core/일반 예외로 변환 후 던짐
+ Log.e(TAG, "[소셜 프로필 완료 실패] ${e.message}")
+ throw Exception(e.message ?: "소셜 프로필 완료 실패")
+ }
+ }
+
+ override suspend fun refreshUserInfo(userId: Long) {
+ getUserInfo(userId)
+ // getUserInfo 내부에서 sessionStore.saveLogin()
+ }
+
+ override suspend fun updateUserProfile(
+ nickname: String,
+ jobId: Long,
+ jobName: String,
+ purposes: List,
+ interests: List
+ ) {
+ // 서버 DB 수정
+ updateUserInfo(
+ nickname = nickname,
+ jobId = jobId,
+ purposes = purposes,
+ interests = interests
+ )
+
+ // 서버 성공 시 로컬 세션 즉시 반영
+ sessionStore.updateProfile(
+ nickname = nickname,
+ jobId = jobId,
+ jobName = jobName,
+ purposes = purposes,
+ interests = interests
+ )
+ }
+
}
diff --git a/data/src/main/java/com/example/data/mapper/SocialProfileMapper.kt b/data/src/main/java/com/example/data/mapper/SocialProfileMapper.kt
new file mode 100644
index 00000000..715d1aaf
--- /dev/null
+++ b/data/src/main/java/com/example/data/mapper/SocialProfileMapper.kt
@@ -0,0 +1,23 @@
+package com.example.data.mapper
+
+import com.example.core.model.auth.*
+import com.example.data.api.dto.server.SocialProfileRequestDTO
+
+object SocialProfileMapper {
+
+ fun toRequest(
+ nickName: String,
+ gender: Gender,
+ job: Job,
+ purposes: List,
+ interests: List
+ ): SocialProfileRequestDTO {
+ return SocialProfileRequestDTO(
+ nickName = nickName,
+ gender = gender.value,
+ jobId = job.id,
+ purposeList = purposes.map { it.serverKey },
+ interestList = interests.map { it.serverKey }
+ )
+ }
+}
\ No newline at end of file
diff --git a/design/src/main/java/com/example/design/theme/font/Laundrygothic.kt b/design/src/main/java/com/example/design/theme/font/Laundrygothic.kt
new file mode 100644
index 00000000..4eeef122
--- /dev/null
+++ b/design/src/main/java/com/example/design/theme/font/Laundrygothic.kt
@@ -0,0 +1,16 @@
+package com.example.design.theme.font
+
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import com.example.design.R
+
+// 큐레이션 화면 달력 아이콘 폰트체 추가했습니다.
+
+data object Laundrygothic : ThemeFontScheme(
+ font = FontFamily(
+ Font(R.font.laundrygothic_bold, FontWeight.Bold, FontStyle.Normal),
+ Font(R.font.laundrygothic_regular, FontWeight.Normal, FontStyle.Normal)
+ )
+)
diff --git a/design/src/main/res/font/laundrygothic_bold.ttf b/design/src/main/res/font/laundrygothic_bold.ttf
new file mode 100644
index 00000000..161ed26e
Binary files /dev/null and b/design/src/main/res/font/laundrygothic_bold.ttf differ
diff --git a/design/src/main/res/font/laundrygothic_regular.ttf b/design/src/main/res/font/laundrygothic_regular.ttf
new file mode 100644
index 00000000..89e0641b
Binary files /dev/null and b/design/src/main/res/font/laundrygothic_regular.ttf differ
diff --git a/feature/curation/build.gradle.kts b/feature/curation/build.gradle.kts
index 9a6ab88c..aec24780 100644
--- a/feature/curation/build.gradle.kts
+++ b/feature/curation/build.gradle.kts
@@ -52,6 +52,7 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.media3.common.ktx)
implementation(libs.androidx.compose.foundation)
+ implementation(libs.androidx.foundation.layout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -75,4 +76,6 @@ dependencies {
// Coil
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
implementation("io.coil-kt.coil3:coil-svg:3.0.4")
+
+ implementation("com.google.accompanist:accompanist-systemuicontroller:0.34.0")
}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/CurationApp.kt b/feature/curation/src/main/java/com/example/curation/CurationApp.kt
index e81c015b..f35a22cf 100644
--- a/feature/curation/src/main/java/com/example/curation/CurationApp.kt
+++ b/feature/curation/src/main/java/com/example/curation/CurationApp.kt
@@ -1,6 +1,8 @@
package com.example.linku_android.curation
-import androidx.compose.runtime.Composable
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
@@ -9,13 +11,15 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import com.example.curation.CurationDetailViewModel
import com.example.curation.CurationViewModel
-import com.example.curation.ui.CurationDetailScreen
+import com.example.curation.ui.screen.CurationDetailScreen
import com.example.curation.ui.CurationScreen
+import com.example.curation.ui.screen.detail.CurationMonthDetailScreen
/**
* 큐레이션 기능의 내비게이션 그래프 정의
* 이건 아예 전면 변경이라 일단 MainAPP에서 분리만 시켰습니다.
*/
+@OptIn (ExperimentalSharedTransitionApi::class)
fun NavGraphBuilder.curationGraph(
navigator: NavHostController,
showNavBar: (Boolean) -> Unit
@@ -24,50 +28,88 @@ fun NavGraphBuilder.curationGraph(
startDestination = "curation_list",
route = "curation"
) {
- // 1. 리스트 화면
composable("curation_list") { backStackEntry ->
- // 리스트 화면에서는 바텀바 표시
showNavBar(true)
- // 부모 그래프(curation_graph)의 스코프를 가져옴
val parentEntry = remember(backStackEntry) {
navigator.getBackStackEntry("curation")
}
val curationVm: CurationViewModel = hiltViewModel(parentEntry)
- CurationScreen(
- viewModel = curationVm,
- onOpenDetail = { userId, curationId ->
- navigator.navigate("curation_detail/$userId/$curationId") {
- launchSingleTop = true
- }
- }
- )
+ CurationScreen(viewModel = curationVm)
}
- // 2. 상세 화면
- composable("curation_detail/{userId}/{curationId}") { backStack ->
- // 상세 화면 진입 시 필요하다면 바텀바를 숨길 수도 있음 (현재는 유지 중)
+ composable(
+ route = "curation_month_detail/{curationId}?imageUrl={imageUrl}&cardIndex={cardIndex}",
+// enterTransition = { EnterTransition.None },
+// exitTransition = { ExitTransition.None }
+ ) { backStack ->
+ showNavBar(false)
- val userId = backStack.arguments?.getString("userId")?.toLong() ?: 0L
val curationId = backStack.arguments?.getString("curationId")?.toLong() ?: 0L
+ val imageUrl = backStack.arguments?.getString("imageUrl")
+ val cardIndex = backStack.arguments?.getString("cardIndex")?.toInt() ?: 0
- val parentEntry = remember(backStack) {
- navigator.getBackStackEntry("curation")
- }
-
- // 공유 ViewModel (부모 스코프)
- val sharedVm: CurationViewModel = hiltViewModel(parentEntry)
- // 상세 전용 ViewModel (현재 상세화면 스코프)
- val detailVm: CurationDetailViewModel = hiltViewModel(backStack)
-
- CurationDetailScreen(
- userId = userId,
+ CurationMonthDetailScreen(
curationId = curationId,
- detailViewModel = detailVm,
- homeViewModel = sharedVm,
+ imageUrl = imageUrl,
onBack = { navigator.popBackStack() }
)
}
}
-}
\ No newline at end of file
+}
+//fun NavGraphBuilder.curationGraph(
+// navigator: NavHostController,
+// showNavBar: (Boolean) -> Unit
+//) {
+// navigation(
+// startDestination = "curation_list",
+// route = "curation"
+// ) {
+// // 1. 리스트 화면
+// composable("curation_list") { backStackEntry ->
+// // 리스트 화면에서는 바텀바 표시
+// showNavBar(true)
+//
+// // 부모 그래프(curation_graph)의 스코프를 가져옴
+// val parentEntry = remember(backStackEntry) {
+// navigator.getBackStackEntry("curation")
+// }
+// val curationVm: CurationViewModel = hiltViewModel(parentEntry)
+//
+// CurationScreen(
+// viewModel = curationVm,
+// onOpenDetail = { userId, curationId ->
+// navigator.navigate("curation_detail/$userId/$curationId") {
+// launchSingleTop = true
+// }
+// }
+// )
+// }
+//
+// // 2. 상세 화면
+// composable("curation_detail/{userId}/{curationId}") { backStack ->
+// // 상세 화면 진입 시 필요하다면 바텀바를 숨길 수도 있음 (현재는 유지 중)
+//
+// val userId = backStack.arguments?.getString("userId")?.toLong() ?: 0L
+// val curationId = backStack.arguments?.getString("curationId")?.toLong() ?: 0L
+//
+// val parentEntry = remember(backStack) {
+// navigator.getBackStackEntry("curation")
+// }
+//
+// // 공유 ViewModel (부모 스코프)
+// val sharedVm: CurationViewModel = hiltViewModel(parentEntry)
+// // 상세 전용 ViewModel (현재 상세화면 스코프)
+// val detailVm: CurationDetailViewModel = hiltViewModel(backStack)
+//
+// CurationDetailScreen(
+// userId = userId,
+// curationId = curationId,
+// detailViewModel = detailVm,
+// homeViewModel = sharedVm,
+// onBack = { navigator.popBackStack() }
+// )
+// }
+// }
+//}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/CurationFragment.kt b/feature/curation/src/main/java/com/example/curation/CurationFragment.kt
deleted file mode 100644
index 0aa08ce1..00000000
--- a/feature/curation/src/main/java/com/example/curation/CurationFragment.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.example.curation
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.compose.ui.platform.ComposeView
-import androidx.fragment.app.Fragment
-import com.example.curation.ui.CurationScreen
-
-//Jetpack Compose 기반의 큐레이션 화면을 띄우는 Fragment
-//CurationFragment.kt
-//역할: Jetpack Compose 기반의 큐레이션 화면을 띄우는 Fragment
-//
-//기능: ComposeView를 통해 CurationScreen()을 보여줌
-//
-//추후 연동: ViewModel, Navigation Graph 등에 연결
-
-class CurationFragment : Fragment() {
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- return ComposeView(requireContext()).apply {
- setContent {
- CurationScreen()
- }
- }
- }
-}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/CurationTypography.kt b/feature/curation/src/main/java/com/example/curation/CurationTypography.kt
deleted file mode 100644
index 0b622d7b..00000000
--- a/feature/curation/src/main/java/com/example/curation/CurationTypography.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.example.curation
-
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import com.example.curation.R
-
-val Paperlogy = FontFamily(
- Font(R.font.paperlogy_1thin, FontWeight.Thin),
- Font(R.font.paperlogy_2extralight, FontWeight.ExtraLight),
- Font(R.font.paperlogy_3light, FontWeight.Light),
- Font(R.font.paperlogy_4regular, FontWeight.Normal),
- Font(R.font.paperlogy_5medium, FontWeight.Medium),
- Font(R.font.paperlogy_6semibold, FontWeight.SemiBold),
- Font(R.font.paperlogy_7bold, FontWeight.Bold),
- Font(R.font.paperlogy_8extrabold, FontWeight.ExtraBold),
- Font(R.font.paperlogy_9black, FontWeight.Black)
-)
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/CurationViewModel.kt b/feature/curation/src/main/java/com/example/curation/CurationViewModel.kt
index 64396a62..c5fcdae2 100644
--- a/feature/curation/src/main/java/com/example/curation/CurationViewModel.kt
+++ b/feature/curation/src/main/java/com/example/curation/CurationViewModel.kt
@@ -45,8 +45,8 @@ class CurationViewModel @Inject constructor(
private val _errorMessage = MutableStateFlow(null)
val errorMessage: StateFlow = _errorMessage
- private val _nickname = MutableStateFlow(null)
- val nickname: StateFlow = _nickname
+ private val _nickname = MutableStateFlow("세나")
+ val nickname: StateFlow = _nickname
private val _recentCuration = MutableStateFlow(null)
val recentCuration: StateFlow = _recentCuration
diff --git a/feature/curation/src/main/java/com/example/curation/LinkItem.kt b/feature/curation/src/main/java/com/example/curation/LinkItem.kt
deleted file mode 100644
index 55787edd..00000000
--- a/feature/curation/src/main/java/com/example/curation/LinkItem.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.example.curation
-
-
-//역할: 추천 링크 카드 모델
-//
-//속성 예시: title, imageRes, url
-//
-//활용: CurationRecommendedLinksSection.kt 내부에서 사용
-data class LinkItem(
- val title: String,
- val imageRes: Int,
- val url: String
-)
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/CurationScreen.kt b/feature/curation/src/main/java/com/example/curation/ui/CurationScreen.kt
index 72db2f38..3875c195 100644
--- a/feature/curation/src/main/java/com/example/curation/ui/CurationScreen.kt
+++ b/feature/curation/src/main/java/com/example/curation/ui/CurationScreen.kt
@@ -1,539 +1,931 @@
package com.example.curation.ui
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Column
-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.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.example.curation.ui.recommend_list.CurationRecommendedLinksSection
-import com.example.curation.R
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.width
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.curation.CurationViewModel
-import com.example.curation.ui.list_card.LikedCurationCard
-import com.example.curation.Paperlogy
-import com.example.design.theme.LocalColorTheme
-import java.time.LocalDate
-import java.time.format.DateTimeFormatter
-import java.util.Locale
-import androidx.compose.ui.platform.LocalUriHandler
-import androidx.compose.ui.text.style.TextAlign
+import com.example.design.top.bar.TopBar
+import com.example.design.util.scaler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import com.example.curation.ui.main_card.CurationHighlightSection
-import com.example.curation.ui.main_card.HighlightCurationCard
-import com.example.design.top.search.SearchBarTopSheet
-import com.example.core.model.RecommendedLink
-import com.example.curation.ui.list_card.LikedCurationSkeleton
-import com.example.curation.ui.recommend_list.RecommendedLinkCardSkeleton
-import com.example.design.top.bar.TopBar
-
-// 간단 확장함수
-private fun String.toLabel(): String = runCatching {
- val y = substring(0, 4).toInt()
- val m = substring(5, 7).toInt()
- "${y}년 ${m}월호"
-}.getOrElse { this }
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.graphics.Color
+import com.example.curation.R
+import com.example.curation.ui.effect.highlight.RadialGradientCircle
+import com.example.curation.ui.main_card.CurationMainCardPager
+import com.example.curation.ui.screen.detail.CurationMonthDetailOverlay
+import com.example.design.theme.font.Paperlogy
+import androidx.compose.foundation.pager.rememberPagerState
+import com.example.curation.ui.calendar.CalendarBox
-fun ensureHttpScheme(raw: String): String =
- if (raw.startsWith("http://") || raw.startsWith("https://")) raw
- else "https://$raw"
+@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CurationScreen(
viewModel: CurationViewModel = hiltViewModel(),
- onOpenDetail: (Long, Long) -> Unit = { _, _ -> }
+ //onOpenDetail: (userId: Long, curationId: Long, imageUrl: String?, cardIndex: Int) -> Unit = { _, _, _, _ -> } // 파라미터 4개로 변경
) {
- // 추가: TopSheet 표시 여부
- var showSearch by remember { mutableStateOf(false) }
- val uri = LocalUriHandler.current
val nickname by viewModel.nickname.collectAsState()
- val userId by viewModel.userId.collectAsState(initial = -1L)
- val currentCurationId by viewModel.currentCurationId.collectAsState(initial = -1L)
-
- val canOpenDetail = userId > 0 && currentCurationId > 0
-
- LaunchedEffect(canOpenDetail) {
- if (canOpenDetail) {
- viewModel.loadHomeRecommendedLinksTop2(userId, currentCurationId)
- }
- }
-
- val homeLinksState by viewModel.homeLinks.collectAsState()
-
+ // Pager 상태를 Screen에서 고정
+ val pagerState = rememberPagerState(
+ initialPage = 0,
+ pageCount = { 3 }
+ )
- //좋아요 리스트 상태 수집
- val likedItems by viewModel.likedCurations.collectAsState()
- val likedLoading by viewModel.likedLoading.collectAsState()
+ // 디테일 오버레이 상태
+ var showDetail by remember { mutableStateOf(false) }
+ var selectedImageUrl by remember { mutableStateOf(null) }
+ var selectedPage by remember { mutableStateOf(0) }
- // 현재 월을 "8월" 같은 형식으로 가져옴
- val currentMonth = remember {
- LocalDate.now().format(DateTimeFormatter.ofPattern("M월", Locale.KOREAN))
- }
- // 닉네임 불러오기
LaunchedEffect(Unit) {
viewModel.loadNickname()
}
- Scaffold(
- topBar = {
- TopBar(
- onClickSearch = { viewModel.updateSearchTopSheetVisible(true) }
- )
- },
- containerColor = LocalColorTheme.current.white
- ) { innerPadding ->
+ SharedTransitionLayout {
+ Box(modifier = Modifier.fillMaxSize()) {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize(),
- contentPadding = PaddingValues(
- top = innerPadding.calculateTopPadding(),//탑바 고정.
- bottom = 32.dp
- )
- ) {
-
- // 현재 날짜에서 전달 구하기
- val prevMonthLabel = LocalDate.now()
- .minusMonths(1)
- .format(DateTimeFormatter.ofPattern("M월", Locale.KOREAN))
-
- item {
- // 제목 영역 (24dp)
- Text(
- text = "${nickname}님을 위한 ${prevMonthLabel}의 큐레이션",
- style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = 20.sp
- ),
- color = LocalColorTheme.current.black,
- modifier = Modifier
- .padding(start = 24.dp, end = 24.dp, top = 19.dp)
+ // 전체 화면 기준 배경
+ // 핑크 원
+ RadialGradientCircle(
+ color = Color(0xFFC800FF),
+ modifier = Modifier.offset(
+ x = (-88).scaler,
+ y = (-12).scaler
)
-
- Spacer(modifier = Modifier.height(20.dp))
-
- // 하이라이트 카드 (좌우 20dp)
- CurationHighlightSection(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 20.dp),
- viewModel = viewModel,
- onOpenDetail = { onOpenDetail(userId, currentCurationId) }
- )
-
- Spacer(modifier = Modifier.height(25.dp))
- }
-
-
-
- // 2. 추천 링크
- // 내부에서 padding 제거하고 modifier로 전달
- item {
-
- Text(
- text = "추천 링크",
- style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = 20.sp
- ),
- color = LocalColorTheme.current.black,
- modifier = Modifier.padding(start = 24.dp)
+ )
+ //블루 원
+ RadialGradientCircle(
+ color = Color(0xFF2C6FFF), //color = Color(0xFFC800FF),
+ modifier = Modifier.offset(
+ x = 24.scaler,
+ y = (-6).scaler
)
-
- Spacer(modifier = Modifier.height(20.dp))
- when {
- //로딩 시 스켈레톤 + 쉬머
- homeLinksState.loading && homeLinksState.items.isEmpty() -> {
- Column(modifier = Modifier.padding(horizontal = 16.dp)) {
- repeat(2) {
- RecommendedLinkCardSkeleton()
- Spacer(Modifier.height(10.dp))
- }
- }
- }
- else -> {
- CurationRecommendedLinksSection(
- modifier = Modifier.fillMaxWidth(),
- links = homeLinksState.items,
- loading = homeLinksState.loading,
- onRetry = {
- if (canOpenDetail) {
- viewModel.loadHomeRecommendedLinksTop2(
- userId,
- currentCurationId
- )
- }
- },
- onClick = { url -> runCatching { uri.openUri(url) } }
- )
- }
- }
-
- Spacer(modifier = Modifier.height(2.dp)) //이거 간격이 큰데
-
- // 3. 좋아요한 큐레이션 제목
- Column(
- modifier = Modifier.padding(
- start = 24.dp,
- end = 24.dp,
- top = 24.dp,
- bottom = 0.dp
+ )
+ // 로고
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_logo),
+ contentDescription = "logo",
+ modifier = Modifier
+ .offset(
+ x = 224.scaler,
+ y = 95.scaler
)
- ) {
- Text(
- text = "${nickname}님이 좋아요한 큐레이션",
- style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = 20.sp
- ),
- color = LocalColorTheme.current.black
+ .size(
+ width = 197.scaler,
+ height = 140.scaler
)
- }
+ )
- Spacer(modifier = Modifier.height(18.dp))
+ // 실제 화면
+ Scaffold(
+ topBar = {
+ TopBar(showSearchBar = false, backgroundColor = null)
+ },
+ containerColor = Color.Transparent
+ ) { innerPadding ->
+
+ CurationScreenContent(
+ nickname = nickname.ifBlank { "세나" },
+ pagerState = pagerState,
+ isDetailOpen = showDetail,
+ // TODO: 실제 curationId
+ onCardClick = { index, imageUrl ->
+ selectedPage = index
+ selectedImageUrl = imageUrl
+ showDetail = true
+ },
+ modifier = Modifier.padding(top = innerPadding.calculateTopPadding())
+ )
}
-
-
-
- item {
- // 어댑터로 변환 (title은 고정문구/월 라벨 포맷)
- val uiItems = likedItems.map {
- UICurationItem(
- title = "링큐 큐레이션",
- date = it.month.toLabel(), // "2025-07" -> "2025년 7월호"
- imageUrl = it.thumbnailUrl,
- liked = true
- )
- }
-
- Column(modifier = Modifier.padding(horizontal = 16.dp)) {
-
-
- when {
- likedLoading && uiItems.isEmpty() -> {
- Spacer(Modifier.height(10.dp))
- repeat(3) {
- LikedCurationSkeleton() //스켈레톤 + 쉬머 적용함.
- Spacer(Modifier.height(10.dp))
- }
- }
-
- uiItems.isEmpty() -> {
- LikedCurationEmptyState(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp)
- )
- }
-
- else -> {
- uiItems.forEachIndexed { idx, item ->
- val domain = likedItems.getOrNull(idx)
-
- LikedCurationCard(
- item = item,
- onCardClick = {
- // 카드 탭 → 상세
- if (userId > 0 && domain != null) {
- onOpenDetail(userId, domain.id)
- }
- },
- onHeartClick = {
- likedItems.getOrNull(idx)?.let { domain ->
- viewModel.unlikeFromLikedList(domain.id) // 서버 취소 + 낙관적 제거
- }
- }
- )
- Spacer(Modifier.height(10.dp))
- }
- }
- }
- }
+ // 디테일 오버레이 (CurationScreen 위에 띄움)
+ if (showDetail) {
+ CurationMonthDetailOverlay(
+ page = selectedPage,
+ imageUrl = selectedImageUrl,
+ onBack = { showDetail = false }
+ )
}
}
}
-
-
- // 검색창 탑 시트
- SearchBarTopSheet(
- visible = viewModel.searchTopSheetVisible,
- onLinkClick = {},
- onDismiss = { viewModel.updateSearchTopSheetVisible(false) },
- onQueryChange = { viewModel.fastSearch(it) },
- onQuerySave = { viewModel.addRecentQuery(it) },
- onQueryDelete = { viewModel.removeRecentQuery(it) },
- onQueryClear = { viewModel.clearRecentQuery() },
- fastSearchItems = viewModel.fastSearchItems.collectAsState().value,
- recentQueries = viewModel.recentQueryList.collectAsState().value.map{it.text}
- )
- }
-
-
-
-//좋아요한 큐레이션이 없는 경우 보여지는 화면.
+}
+// CurationScreen.kt
@Composable
-fun LikedCurationEmptyState(
- modifier: Modifier = Modifier,
- emptyIconRes: Int = R.drawable.img_curation_liked_null // 점선+링크가 합쳐진 PNG
+private fun CurationScreenContent(
+ nickname: String,
+ pagerState: PagerState,
+ isDetailOpen: Boolean,
+ onCardClick: (index: Int, imageUrl: String?) -> Unit,
+ modifier: Modifier = Modifier
) {
Column(
- modifier = modifier
- .fillMaxWidth()
- .padding(top = 8.dp), // 필요시 조절
- horizontalAlignment = Alignment.CenterHorizontally
+ modifier = modifier.fillMaxSize()
) {
- // 아이콘(점선 박스 포함) 크기 고정
+ // 타이틀 이미지
Image(
- painter = painterResource(id = emptyIconRes),
+ painter = painterResource(id = R.drawable.img_curation_title),
contentDescription = null,
- modifier = Modifier.size(82.dp),
- contentScale = ContentScale.Fit
+ modifier = Modifier
+ .padding(start = 24.scaler, top = 28.scaler)
+ .size(width = 102.scaler, height = 15.scaler)
)
- Spacer(Modifier.height(12.dp))
+ Spacer(modifier = Modifier.height(12.scaler))
+ // 닉네임 텍스트
Text(
- text = "아직 좋아요 한 큐레이션이 아직 없어요!",
- style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Medium,
- fontSize = 15.sp,
- color = LocalColorTheme.current.black
+ text = "${nickname}님을 위한 링큐레이션",
+ style = TextStyle(
+ fontSize = 22.sp,
+ lineHeight = 30.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(700),
+ color = Color(0xFF1451D5)
),
- textAlign = TextAlign.Center
+ modifier = Modifier.padding(horizontal = 24.scaler)
)
- Spacer(Modifier.height(4.dp))
+ Spacer(modifier = Modifier.height(26.scaler))
- Text(
- text = "좋아요를 눌러보러 갈까요?",
- style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Medium,
- fontSize = 13.sp,
- color = Color(0xFF87898F)
- ),
- textAlign = TextAlign.Center
+ // 카드 페이저 (고정된 위치에서 시작)
+ CurationMainCardPager(
+ imageUrls = listOf(null, null, null),
+ pagerState = pagerState,
+ isDetailOpen = isDetailOpen,
+ onCardClick = onCardClick
)
- }
-}
-
-@Composable
-private fun LinksPreparingHome(modifier: Modifier = Modifier) {
- Column(
- modifier = modifier
- .height(60.dp), // 필요시 조절
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
+ Spacer(modifier = Modifier.height(33.scaler))
- Spacer(Modifier.height(12.dp))
-
- // 설명
Text(
- "AI가 한달 감정&상황 데이터를 바탕으로\n추천 링크를 준비하고 있어요",
- style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Medium,
- fontSize = 13.sp,
- color = Color(0xFF87898F)
+ text = "지난 큐레이션",
+ style = TextStyle(
+ fontSize = 22.sp,
+ lineHeight = 30.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(700),
+ color = Color(0xFF000208)
),
- textAlign = androidx.compose.ui.text.style.TextAlign.Center
+ modifier = Modifier.padding(horizontal = 24.scaler)
)
- Spacer(Modifier.height(14.dp))
+ Spacer(modifier = Modifier.height(18.scaler))
- // 안내 문구
- Text(
- "* 검증 과정으로 인해 추천 링크 수가 제한될 수 있습니다.",
- style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Medium,
- fontSize = 13.sp,
- color = Color(0xFFFF5E5E)
- ),
- textAlign = androidx.compose.ui.text.style.TextAlign.Center
+ CalendarBox(
+ modifier = Modifier.padding(horizontal = 18.scaler)
)
+
}
}
-@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 390, heightDp = 2000)
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Preview(
+ showBackground = true,
+ backgroundColor = 0xFFFFFFFF, // 어두운 배경
+ widthDp = 390,
+ heightDp = 844
+)
@Composable
-fun PreviewCurationScreenExact() {
-
- val previewLinks = listOf(
- RecommendedLink(
- isInternal = true,
- userLinkuId = 1L,
- title = "성신여대 홈페이지",
- url = "https://sungshin.ac.kr",
- imageUrl = null,
- domain = "sungshin.ac.kr",
- domainImageUrl = null,
- categories = listOf("기타")
- ),
- RecommendedLink(
- isInternal = false,
- userLinkuId = null,
- title = "나만의 자기관리 체크리스트 만들기 - Adobe",
- url = "https://adobe.com",
- imageUrl = null,
- domain = "adobe.com",
- domainImageUrl = null,
- categories = listOf("정보")
- )
- )
-
- val previewLiked = listOf(
- UICurationItem(
- title = "링큐 큐레이션",
- date = "2025년 7월호",
- imageUrl = null,
- liked = true
- ),
- UICurationItem(
- title = "링큐 큐레이션",
- date = "2025년 6월호",
- imageUrl = null,
- liked = true
- )
+fun PreviewCurationScreen() {
+ val pagerState = rememberPagerState(
+ initialPage = 0,
+ pageCount = { 3 }
)
- val uri = LocalUriHandler.current
+ SharedTransitionLayout {
+ Box(modifier = Modifier.fillMaxSize()) {
- BoxWithConstraints(
- modifier = Modifier.fillMaxSize()
- ) {
- val width = maxWidth
+ // 핑크 원
+ RadialGradientCircle(
+ color = Color(0xFFC800FF),
+ modifier = Modifier.offset(
+ x = (-88).scaler,
+ y = (-12).scaler
+ )
+ )
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .background(LocalColorTheme.current.white),
- contentPadding = PaddingValues(bottom = 32.dp)
- ) {
- item {
- TopBar(
- showSearchBar = true,
- onClickSearch = {}, // Preview라서 빈 람다
- onClickAlarm = {}
- ) }
-
- item { Spacer(Modifier.height(19.dp)) }
-
- item {
- Text(
- text = "세나님을 위한 11월의 큐레이션",
- fontFamily = Paperlogy,
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold,
- color = LocalColorTheme.current.black,
- modifier = Modifier.padding(horizontal = 24.dp)
+ // 블루 원
+ RadialGradientCircle(
+ color = Color(0xFF2C6FFF),
+ modifier = Modifier.offset(
+ x = 24.scaler,
+ y = (-6).scaler
)
- }
+ )
- item { Spacer(Modifier.height(20.dp)) }
+ // 로고
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_logo),
+ contentDescription = "logo",
+ modifier = Modifier
+ .offset(
+ x = 224.scaler,
+ y = 95.scaler
+ )
+ .size(
+ width = 197.scaler,
+ height = 140.scaler
+ )
+ )
- item {
- HighlightCurationCard(
- imageUrl = null,
- title = "링큐 큐레이션",
- date = "2025년 11월호",
- liked = true,
- likeBusy = false,
- modifier = Modifier
- .width(width)
- .padding(horizontal = 20.dp)
+ Scaffold(
+ topBar = {
+ TopBar(showSearchBar = false, backgroundColor = null)
+ },
+ containerColor = Color.Transparent
+ ) { innerPadding ->
+ CurationScreenContent(
+ nickname = "세나",
+ pagerState = pagerState,
+ isDetailOpen = false,
+ onCardClick = { _, _ -> },
+ modifier = Modifier.padding(top = innerPadding.calculateTopPadding())
)
}
+ }
+ }
+}
+/*
+@Composable
+private fun CurationScreenContent(
+ nickname: String,
+ pagerState: PagerState,
+ onCardClick: (index: Int, imageUrl: String?) -> Unit, // 추가
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 32.dp)
+ ) {
+ item {
+ Column {
- item { Spacer(Modifier.height(25.dp)) }
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_title),
+ contentDescription = null,
+ modifier = Modifier
+ .padding(
+ start = 24.scaler,
+ top = 28.scaler
+ )
+ .size(
+ width = 102.scaler,
+ height = 15.scaler
+ )
+ )
- item {
+ Spacer(modifier = Modifier.height(12.scaler))
Text(
- text = "추천 링크",
- style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = 20.sp
+ text = "${nickname}님을 위한 링큐레이션",
+ style = TextStyle(
+ fontSize = 22.sp,
+ lineHeight = 30.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(700),
+ color = Color(0xFF1451D5)
),
- color = LocalColorTheme.current.black,
- modifier = Modifier.padding(start = 24.dp)
+ modifier = Modifier.padding(horizontal = 20.scaler)
)
+ Spacer(modifier = Modifier.height(26.scaler))
- Spacer(modifier = Modifier.height(20.dp))
+ CurationMainCardPager(
+ imageUrls = listOf(null, null, null),
+ pagerState = pagerState,
+ onCardClick = onCardClick // 전달
+ )
- CurationRecommendedLinksSection(
- modifier = Modifier.fillMaxWidth(),
- links = previewLinks,
- loading = false,
- onRetry = {},
- onClick = { url -> runCatching { uri.openUri(url) } }
- )
}
+ }
+ }
+}*/
- item { Spacer(Modifier.height(25.dp)) }
- item {
- Text(
- text = "세나님이 좋아요한 큐레이션",
- fontFamily = Paperlogy,
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold,
- color = LocalColorTheme.current.black,
- modifier = Modifier.padding(start = 24.dp, end = 24.dp)
+/*@Preview(
+ showBackground = true,
+ backgroundColor = 0xFFFFFFFF
+)
+@Composable
+fun PreviewCurationScreenFull() {
+ Box(modifier = Modifier.fillMaxSize()) {
+
+ // 배경 원
+ RadialGradientCircle(
+ color = Color(0xFFC800FF),
+ modifier = Modifier.offset(
+ x = (-88).scaler,
+ y = (0).scaler
+ )
+ )
+
+ RadialGradientCircle(
+ color = Color(0xFF2C6FFF),
+ modifier = Modifier.offset(
+ x = 24.scaler,
+ y = (-12).scaler
+ )
+ )
+
+ // 로고
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_logo),
+ contentDescription = null,
+ modifier = Modifier
+ .offset(
+ x = 224.scaler,
+ y = 95.scaler
)
- }
+ .size(
+ width = 197.scaler,
+ height = 140.scaler
+ )
+ )
- item { Spacer(Modifier.height(18.dp)) }
+ // 콘텐츠
+ Scaffold(
+ topBar = {
+ TopBar(showSearchBar = false,backgroundColor = null)
+ },
+ containerColor = Color.Transparent
+ ) { innerPadding ->
- items(previewLiked) { item ->
- Column(modifier = Modifier.padding(horizontal = 16.dp)) {
- LikedCurationCard(
- item = item,
- onCardClick = {},
- onHeartClick = {}
- )
- Spacer(Modifier.height(10.dp))
- }
- }
+ CurationScreenContent(
+ nickname = "세나",
+ onCardClick = { _, _ -> },
+
+ modifier = Modifier.padding(
+ top = innerPadding.calculateTopPadding()
+ )
+ )
}
}
-}
\ No newline at end of file
+}
+*/
+
+//package com.example.curation.ui
+//
+//
+//import androidx.compose.foundation.Image
+//import androidx.compose.foundation.background
+//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.shape.RoundedCornerShape
+//import androidx.compose.material3.MaterialTheme
+//import androidx.compose.material3.Text
+//import androidx.compose.material3.Icon
+//import androidx.compose.runtime.Composable
+//import androidx.compose.ui.Alignment
+//import androidx.compose.ui.Modifier
+//import androidx.compose.ui.graphics.Color
+//import androidx.compose.ui.res.painterResource
+//import androidx.compose.ui.tooling.preview.Preview
+//import androidx.compose.ui.unit.dp
+//import com.example.curation.ui.recommend_list.CurationRecommendedLinksSection
+//import com.example.curation.R
+//import androidx.compose.foundation.lazy.LazyColumn
+//import androidx.compose.foundation.lazy.items
+//import androidx.compose.foundation.layout.PaddingValues
+//import androidx.compose.foundation.clickable
+//import androidx.compose.foundation.layout.Arrangement
+//import androidx.compose.foundation.layout.Box
+//import androidx.compose.foundation.layout.BoxWithConstraints
+//import androidx.compose.foundation.layout.width
+//import androidx.compose.material3.Scaffold
+//import androidx.compose.runtime.LaunchedEffect
+//import androidx.compose.runtime.collectAsState
+//import androidx.compose.runtime.getValue
+//import androidx.compose.runtime.remember
+//import androidx.compose.ui.draw.clip
+//import androidx.compose.ui.layout.ContentScale
+//import androidx.compose.ui.text.font.FontWeight
+//import androidx.compose.ui.unit.sp
+//import androidx.hilt.navigation.compose.hiltViewModel
+//import com.example.curation.CurationViewModel
+//import com.example.curation.ui.list_card.LikedCurationCard
+//import com.example.curation.Paperlogy
+//import com.example.design.theme.LocalColorTheme
+//import com.example.design.theme.color.Basic
+//import com.example.design.R as Res
+//import java.time.LocalDate
+//import java.time.format.DateTimeFormatter
+//import java.util.Locale
+//import androidx.compose.ui.platform.LocalUriHandler
+//import androidx.compose.ui.text.style.TextAlign
+//import androidx.compose.runtime.mutableStateOf
+//import androidx.compose.runtime.setValue
+//import com.example.curation.ui.main_card.CurationHighlightSection
+//import com.example.curation.ui.main_card.HighlightCurationCard
+//import com.example.design.top.search.SearchBarTopSheet
+//import com.example.core.model.RecommendedLink
+//import com.example.curation.ui.list_card.LikedCurationSkeleton
+//import com.example.curation.ui.recommend_list.RecommendedLinkCardSkeleton
+//import com.example.design.top.bar.TopBar
+//
+//// 간단 확장함수
+//private fun String.toLabel(): String = runCatching {
+// val y = substring(0, 4).toInt()
+// val m = substring(5, 7).toInt()
+// "${y}년 ${m}월호"
+//}.getOrElse { this }
+//
+//fun ensureHttpScheme(raw: String): String =
+// if (raw.startsWith("http://") || raw.startsWith("https://")) raw
+// else "https://$raw"
+//
+//@Composable
+//fun CurationScreen(
+// viewModel: CurationViewModel = hiltViewModel(),
+// onOpenDetail: (Long, Long) -> Unit = { _, _ -> }
+//) {
+// // 추가: TopSheet 표시 여부
+// var showSearch by remember { mutableStateOf(false) }
+// val uri = LocalUriHandler.current
+// val nickname by viewModel.nickname.collectAsState()
+//
+// val userId by viewModel.userId.collectAsState(initial = -1L)
+// val currentCurationId by viewModel.currentCurationId.collectAsState(initial = -1L)
+//
+// val canOpenDetail = userId > 0 && currentCurationId > 0
+//
+// LaunchedEffect(canOpenDetail) {
+// if (canOpenDetail) {
+// viewModel.loadHomeRecommendedLinksTop2(userId, currentCurationId)
+// }
+// }
+//
+// val homeLinksState by viewModel.homeLinks.collectAsState()
+//
+//
+// //좋아요 리스트 상태 수집
+// val likedItems by viewModel.likedCurations.collectAsState()
+// val likedLoading by viewModel.likedLoading.collectAsState()
+//
+// // 현재 월을 "8월" 같은 형식으로 가져옴
+// val currentMonth = remember {
+// LocalDate.now().format(DateTimeFormatter.ofPattern("M월", Locale.KOREAN))
+// }
+//
+// // 닉네임 불러오기
+// LaunchedEffect(Unit) {
+// viewModel.loadNickname()
+// }
+//
+// Scaffold(
+// topBar = {
+// TopBar(
+// onClickSearch = { viewModel.updateSearchTopSheetVisible(true) }
+// )
+// },
+// containerColor = LocalColorTheme.current.white
+// ) { innerPadding ->
+//
+// LazyColumn(
+// modifier = Modifier
+// .fillMaxSize(),
+// contentPadding = PaddingValues(
+// top = innerPadding.calculateTopPadding(),//탑바 고정.
+// bottom = 32.dp
+// )
+// ) {
+//
+// // 현재 날짜에서 전달 구하기
+// val prevMonthLabel = LocalDate.now()
+// .minusMonths(1)
+// .format(DateTimeFormatter.ofPattern("M월", Locale.KOREAN))
+//
+// item {
+// // 제목 영역 (24dp)
+// Text(
+// text = "${nickname}님을 위한 ${prevMonthLabel}의 큐레이션",
+// style = MaterialTheme.typography.titleMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Bold,
+// fontSize = 20.sp
+// ),
+// color = LocalColorTheme.current.black,
+// modifier = Modifier
+// .padding(start = 24.dp, end = 24.dp, top = 19.dp)
+// )
+//
+// Spacer(modifier = Modifier.height(20.dp))
+//
+// // 하이라이트 카드 (좌우 20dp)
+// CurationHighlightSection(
+// modifier = Modifier
+// .fillMaxWidth()
+// .padding(horizontal = 20.dp),
+// viewModel = viewModel,
+// onOpenDetail = { onOpenDetail(userId, currentCurationId) }
+// )
+//
+// Spacer(modifier = Modifier.height(25.dp))
+// }
+//
+//
+//
+// // 2. 추천 링크
+// // 내부에서 padding 제거하고 modifier로 전달
+// item {
+//
+// Text(
+// text = "추천 링크",
+// style = MaterialTheme.typography.titleMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Bold,
+// fontSize = 20.sp
+// ),
+// color = LocalColorTheme.current.black,
+// modifier = Modifier.padding(start = 24.dp)
+// )
+//
+// Spacer(modifier = Modifier.height(20.dp))
+// when {
+// //로딩 시 스켈레톤 + 쉬머
+// homeLinksState.loading && homeLinksState.items.isEmpty() -> {
+// Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+// repeat(2) {
+// RecommendedLinkCardSkeleton()
+// Spacer(Modifier.height(10.dp))
+// }
+// }
+// }
+// else -> {
+// CurationRecommendedLinksSection(
+// modifier = Modifier.fillMaxWidth(),
+// links = homeLinksState.items,
+// loading = homeLinksState.loading,
+// onRetry = {
+// if (canOpenDetail) {
+// viewModel.loadHomeRecommendedLinksTop2(
+// userId,
+// currentCurationId
+// )
+// }
+// },
+// onClick = { url -> runCatching { uri.openUri(url) } }
+// )
+// }
+// }
+//
+// Spacer(modifier = Modifier.height(2.dp)) //이거 간격이 큰데
+//
+// // 3. 좋아요한 큐레이션 제목
+// Column(
+// modifier = Modifier.padding(
+// start = 24.dp,
+// end = 24.dp,
+// top = 24.dp,
+// bottom = 0.dp
+// )
+// ) {
+// Text(
+// text = "${nickname}님이 좋아요한 큐레이션",
+// style = MaterialTheme.typography.titleMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Bold,
+// fontSize = 20.sp
+// ),
+// color = LocalColorTheme.current.black
+// )
+// }
+//
+// Spacer(modifier = Modifier.height(18.dp))
+// }
+//
+//
+//
+//
+// item {
+// // 어댑터로 변환 (title은 고정문구/월 라벨 포맷)
+// val uiItems = likedItems.map {
+// UICurationItem(
+// title = "링큐 큐레이션",
+// date = it.month.toLabel(), // "2025-07" -> "2025년 7월호"
+// imageUrl = it.thumbnailUrl,
+// liked = true
+// )
+// }
+//
+// Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+//
+//
+// when {
+// likedLoading && uiItems.isEmpty() -> {
+// Spacer(Modifier.height(10.dp))
+// repeat(3) {
+// LikedCurationSkeleton() //스켈레톤 + 쉬머 적용함.
+// Spacer(Modifier.height(10.dp))
+// }
+// }
+//
+// uiItems.isEmpty() -> {
+// LikedCurationEmptyState(
+// modifier = Modifier
+// .fillMaxWidth()
+// .padding(vertical = 16.dp)
+// )
+// }
+//
+// else -> {
+// uiItems.forEachIndexed { idx, item ->
+// val domain = likedItems.getOrNull(idx)
+//
+// LikedCurationCard(
+// item = item,
+// onCardClick = {
+// // 카드 탭 → 상세
+// if (userId > 0 && domain != null) {
+// onOpenDetail(userId, domain.id)
+// }
+// },
+// onHeartClick = {
+// likedItems.getOrNull(idx)?.let { domain ->
+// viewModel.unlikeFromLikedList(domain.id) // 서버 취소 + 낙관적 제거
+// }
+// }
+// )
+// Spacer(Modifier.height(10.dp))
+// }
+// }
+// }
+// }
+// }
+// }
+// }
+//
+//
+// // 검색창 탑 시트
+// SearchBarTopSheet(
+// visible = viewModel.searchTopSheetVisible,
+// onLinkClick = {},
+// onDismiss = { viewModel.updateSearchTopSheetVisible(false) },
+// onQueryChange = { viewModel.fastSearch(it) },
+// onQuerySave = { viewModel.addRecentQuery(it) },
+// onQueryDelete = { viewModel.removeRecentQuery(it) },
+// onQueryClear = { viewModel.clearRecentQuery() },
+// fastSearchItems = viewModel.fastSearchItems.collectAsState().value,
+// recentQueries = viewModel.recentQueryList.collectAsState().value.map{it.text}
+// )
+// }
+//
+//
+//
+////좋아요한 큐레이션이 없는 경우 보여지는 화면.
+//@Composable
+//fun LikedCurationEmptyState(
+// modifier: Modifier = Modifier,
+// emptyIconRes: Int = R.drawable.img_curation_liked_null // 점선+링크가 합쳐진 PNG
+//) {
+// Column(
+// modifier = modifier
+// .fillMaxWidth()
+// .padding(top = 8.dp), // 필요시 조절
+// horizontalAlignment = Alignment.CenterHorizontally
+// ) {
+// // 아이콘(점선 박스 포함) 크기 고정
+// Image(
+// painter = painterResource(id = emptyIconRes),
+// contentDescription = null,
+// modifier = Modifier.size(82.dp),
+// contentScale = ContentScale.Fit
+// )
+//
+// Spacer(Modifier.height(12.dp))
+//
+// Text(
+// text = "아직 좋아요 한 큐레이션이 아직 없어요!",
+// style = MaterialTheme.typography.titleMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Medium,
+// fontSize = 15.sp,
+// color = LocalColorTheme.current.black
+// ),
+// textAlign = TextAlign.Center
+// )
+//
+// Spacer(Modifier.height(4.dp))
+//
+// Text(
+// text = "좋아요를 눌러보러 갈까요?",
+// style = MaterialTheme.typography.bodyMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Medium,
+// fontSize = 13.sp,
+// color = Color(0xFF87898F)
+// ),
+// textAlign = TextAlign.Center
+// )
+// }
+//}
+//
+//@Composable
+//private fun LinksPreparingHome(modifier: Modifier = Modifier) {
+// Column(
+// modifier = modifier
+// .height(60.dp), // 필요시 조절
+// verticalArrangement = Arrangement.Center,
+// horizontalAlignment = Alignment.CenterHorizontally
+// ) {
+//
+//
+// Spacer(Modifier.height(12.dp))
+//
+// // 설명
+// Text(
+// "AI가 한달 감정&상황 데이터를 바탕으로\n추천 링크를 준비하고 있어요",
+// style = MaterialTheme.typography.bodyMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Medium,
+// fontSize = 13.sp,
+// color = Color(0xFF87898F)
+// ),
+// textAlign = androidx.compose.ui.text.style.TextAlign.Center
+// )
+//
+// Spacer(Modifier.height(14.dp))
+//
+// // 안내 문구
+// Text(
+// "* 검증 과정으로 인해 추천 링크 수가 제한될 수 있습니다.",
+// style = MaterialTheme.typography.bodyMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Medium,
+// fontSize = 13.sp,
+// color = Color(0xFFFF5E5E)
+// ),
+// textAlign = androidx.compose.ui.text.style.TextAlign.Center
+// )
+// }
+//}
+//
+//@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 390, heightDp = 2000)
+//@Composable
+//fun PreviewCurationScreenExact() {
+//
+// val previewLinks = listOf(
+// RecommendedLink(
+// isInternal = true,
+// userLinkuId = 1L,
+// title = "성신여대 홈페이지",
+// url = "https://sungshin.ac.kr",
+// imageUrl = null,
+// domain = "sungshin.ac.kr",
+// domainImageUrl = null,
+// categories = listOf("기타")
+// ),
+// RecommendedLink(
+// isInternal = false,
+// userLinkuId = null,
+// title = "나만의 자기관리 체크리스트 만들기 - Adobe",
+// url = "https://adobe.com",
+// imageUrl = null,
+// domain = "adobe.com",
+// domainImageUrl = null,
+// categories = listOf("정보")
+// )
+// )
+//
+// val previewLiked = listOf(
+// UICurationItem(
+// title = "링큐 큐레이션",
+// date = "2025년 7월호",
+// imageUrl = null,
+// liked = true
+// ),
+// UICurationItem(
+// title = "링큐 큐레이션",
+// date = "2025년 6월호",
+// imageUrl = null,
+// liked = true
+// )
+// )
+//
+// val uri = LocalUriHandler.current
+//
+// BoxWithConstraints(
+// modifier = Modifier.fillMaxSize()
+// ) {
+// val width = maxWidth
+//
+// LazyColumn(
+// modifier = Modifier
+// .fillMaxSize()
+// .background(LocalColorTheme.current.white),
+// contentPadding = PaddingValues(bottom = 32.dp)
+// ) {
+// item {
+// TopBar(
+// showSearchBar = true,
+// //onClickSearch = {}, // Preview라서 빈 람다
+// onClickAlarm = {}
+// ) }
+//
+// item { Spacer(Modifier.height(19.dp)) }
+//
+// item {
+// Text(
+// text = "세나님을 위한 11월의 큐레이션",
+// fontFamily = Paperlogy,
+// fontSize = 20.sp,
+// fontWeight = FontWeight.Bold,
+// color = LocalColorTheme.current.black,
+// modifier = Modifier.padding(horizontal = 24.dp)
+// )
+// }
+//
+// item { Spacer(Modifier.height(20.dp)) }
+//
+// item {
+// HighlightCurationCard(
+// imageUrl = null,
+// title = "링큐 큐레이션",
+// date = "2025년 11월호",
+// liked = true,
+// likeBusy = false,
+// modifier = Modifier
+// .width(width)
+// .padding(horizontal = 20.dp)
+// )
+// }
+//
+// item { Spacer(Modifier.height(25.dp)) }
+//
+// item {
+//
+// Text(
+// text = "추천 링크",
+// style = MaterialTheme.typography.titleMedium.copy(
+// fontFamily = Paperlogy,
+// fontWeight = FontWeight.Bold,
+// fontSize = 20.sp
+// ),
+// color = LocalColorTheme.current.black,
+// modifier = Modifier.padding(start = 24.dp)
+// )
+//
+// Spacer(modifier = Modifier.height(20.dp))
+//
+//
+// CurationRecommendedLinksSection(
+// modifier = Modifier.fillMaxWidth(),
+// links = previewLinks,
+// loading = false,
+// onRetry = {},
+// onClick = { url -> runCatching { uri.openUri(url) } }
+// )
+// }
+//
+// item { Spacer(Modifier.height(25.dp)) }
+//
+// item {
+// Text(
+// text = "세나님이 좋아요한 큐레이션",
+// fontFamily = Paperlogy,
+// fontSize = 20.sp,
+// fontWeight = FontWeight.Bold,
+// color = LocalColorTheme.current.black,
+// modifier = Modifier.padding(start = 24.dp, end = 24.dp)
+// )
+// }
+//
+// item { Spacer(Modifier.height(18.dp)) }
+//
+// items(previewLiked) { item ->
+// Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+// LikedCurationCard(
+// item = item,
+// onCardClick = {},
+// onHeartClick = {}
+// )
+// Spacer(Modifier.height(10.dp))
+// }
+// }
+// }
+// }
+//}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/calendar/CalendarBox.kt b/feature/curation/src/main/java/com/example/curation/ui/calendar/CalendarBox.kt
new file mode 100644
index 00000000..6c3216e3
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/calendar/CalendarBox.kt
@@ -0,0 +1,87 @@
+package com.example.curation.ui.calendar
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.design.theme.font.Paperlogy
+import com.example.design.util.scaler
+
+@Composable
+fun CalendarBox(
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .width(372.scaler)
+ .height(80.scaler)
+ .background(
+ color = Color(0xFFEFF4FF),
+ shape = RoundedCornerShape(size = 22.scaler)
+ )
+ .padding(horizontal = 20.scaler),
+
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // 캘린더 아이콘 영역 TODO : 피그마 대비 좀 사이즈가 작아보임. 디자이너와 협의 후 사이즈 조정 필요.
+ CalendarIconBox(
+ modifier = Modifier.padding(top = 7.scaler)
+ )
+
+ Spacer(modifier = Modifier.width(15.scaler))
+
+ // 텍스트 영역
+ Column(
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "지난 월간 큐레이션을 다시 볼 수 있어요",
+ style = TextStyle(
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(400),
+ color = Color(0xFF43454B)
+ ),
+ modifier = Modifier.padding(top = 2.scaler)
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ Text(
+ text = "월간 큐레이션 모아보기",
+ style = TextStyle(
+ fontSize = 18.sp,
+ lineHeight = 20.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(700),
+ color = Color(0xFF000208)
+ )
+ )
+ }
+ }
+}
+
+
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+fun PreviewCalendarBox() {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CalendarBox()
+ }
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/calendar/CalendarIconBox.kt b/feature/curation/src/main/java/com/example/curation/ui/calendar/CalendarIconBox.kt
new file mode 100644
index 00000000..41a3a5ab
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/calendar/CalendarIconBox.kt
@@ -0,0 +1,116 @@
+package com.example.curation.ui.calendar
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+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.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.curation.R
+import com.example.design.theme.font.Laundrygothic
+import com.example.design.util.scaler
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.remember
+import java.time.LocalDate
+// 월 약어 리스트
+private val MONTH_LABELS = listOf(
+ "JAN", "FEB", "MAR", "APR", "MAY", "JUN",
+ "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"
+)
+
+
+@Composable
+fun CalendarIconBox(
+ modifier: Modifier = Modifier
+) {
+ // 전달 계산 + 연도 넘김도 자동으로 처리됨.
+ val prevMonth = remember {
+ LocalDate.now().minusMonths(1)
+ }
+ val year = prevMonth.year.toString()
+ val monthLabel = MONTH_LABELS[prevMonth.monthValue - 1]
+
+ Box(
+ modifier = modifier
+ .width(45.dp)
+ .height(48.dp),
+ contentAlignment = Alignment.TopCenter
+ ) {
+ // 배경 달력 이미지
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_calendar),
+ contentDescription = null,
+ modifier = Modifier
+ .width(42.scaler)
+ .height(45.scaler)
+ .align(Alignment.TopCenter)
+ )
+
+ // 텍스트 Column
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 7.scaler)
+ ) {
+ // 연도
+ Text(
+ text = year,
+ style = TextStyle(
+ fontSize = 6.sp,
+ fontFamily = Laundrygothic.font,
+ fontWeight = FontWeight(400),
+ color = Color(0xFFFFFFFF),
+ textAlign = TextAlign.Center,
+ ),
+ modifier = Modifier.padding(horizontal = 13.scaler)
+ )
+
+ Spacer(modifier = Modifier.height(7.scaler))
+
+ // 월
+ Text(
+ text = monthLabel,
+ style = TextStyle(
+ fontSize = 14.sp,
+ lineHeight = 30.sp,
+ fontFamily = Laundrygothic.font,
+ fontWeight = FontWeight(700),
+ color = Color(0xFF000000),
+ textAlign = TextAlign.Center,
+ ),
+ modifier = Modifier.padding(horizontal = 1.scaler)
+ )
+ }
+
+
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+fun PreviewCalendarIconBox() {
+ Box(
+ modifier = Modifier
+ .wrapContentSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CalendarIconBox()
+ }
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/detail_card/HighlightCard.kt b/feature/curation/src/main/java/com/example/curation/ui/detail_card/HighlightCard.kt
index 0f6a05a9..a034de61 100644
--- a/feature/curation/src/main/java/com/example/curation/ui/detail_card/HighlightCard.kt
+++ b/feature/curation/src/main/java/com/example/curation/ui/detail_card/HighlightCard.kt
@@ -25,26 +25,18 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
-import com.example.curation.Paperlogy
-import com.example.design.theme.LocalColorTheme
import com.example.curation.R
import com.example.curation.CurationDetailUiState
-import com.example.curation.ui.util.rememberScaleFactor
-import com.example.curation.ui.util.shimmer
-import java.time.LocalDate
-import java.time.format.DateTimeFormatter
+import com.example.design.theme.font.Paperlogy
import java.util.*
-//큐레이션 디테일 화면 맨 위에 있는 카드
+//큐레이션 디테일 화면 맨 위에 있는 카드 -> 곧 지워지지 않을까 싶지만, 재사용성 여부로 남김.
@Composable
fun HighlightCard(
@@ -110,7 +102,7 @@ fun HighlightCard(
color = Color.White,
style = TextStyle(
fontSize = 16.sp,
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontWeight = FontWeight.Medium
),
modifier = Modifier.align(Alignment.Center)
@@ -156,7 +148,7 @@ fun HighlightCard(
color = Color.White,
style = TextStyle(
fontSize = 20.sp,
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontWeight = FontWeight.Bold
),
maxLines = 1
@@ -173,7 +165,7 @@ fun HighlightCard(
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 16.sp,
lineHeight = 22.sp,
- fontFamily = Paperlogy
+ fontFamily = Paperlogy.font,
),
maxLines = 3,
overflow = TextOverflow.Ellipsis
@@ -186,7 +178,7 @@ fun HighlightCard(
color = Color.White,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp,
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontWeight = FontWeight.Medium
)
)
@@ -298,7 +290,7 @@ private fun EmotionChip(text: String) {
text = text,
color = Color(0xFF9A3AB5),
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
),
diff --git a/feature/curation/src/main/java/com/example/curation/ui/effect/highlight/RadialGradientCircle.kt b/feature/curation/src/main/java/com/example/curation/ui/effect/highlight/RadialGradientCircle.kt
new file mode 100644
index 00000000..c81f49f4
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/effect/highlight/RadialGradientCircle.kt
@@ -0,0 +1,82 @@
+package com.example.curation.ui.effect.highlight
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import com.example.design.util.LocalFigmaDimens
+import com.example.design.util.rememberFigmaDimens
+import com.example.design.util.scaler
+
+/**
+ * Figma Ellipse 541
+ * - Size: 321 x 321 (Figma 기준)
+ * - Radial Gradient
+ * - Color: #2C6FFF (12% → 0%)
+ * - Background decorative component
+ */
+@Composable
+fun RadialGradientCircle(
+ modifier: Modifier = Modifier,
+ color: Color,
+ alpha: Float = 0.12f, // 기본값: 피그마 12%
+ size: Dp = 321.scaler
+) {
+ val radiusPx = with(LocalDensity.current) {
+ (size / 2).toPx()
+ }
+
+ Box(
+ modifier = modifier
+ .size(size)
+ .alpha(alpha)
+ .clip(CircleShape)
+ .background(
+ brush = Brush.radialGradient(
+ colorStops = arrayOf(
+ 0f to color, // 중심 100%
+ 1f to Color.Transparent
+ ),
+ center = Offset(radiusPx, radiusPx),
+ radius = radiusPx
+ )
+ )
+ )
+}
+@Preview(showBackground = true)
+@Composable
+private fun RadialGradientCirclePreview() {
+ CompositionLocalProvider(
+ LocalFigmaDimens provides rememberFigmaDimens()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFEDEDED)),
+ contentAlignment = Alignment.Center
+ ) {
+ RadialGradientCircle(
+ color = Color(0xFF2C6FFF),
+ modifier = Modifier.offset(
+ x = (-88).scaler,
+ y = (-12).scaler
+ )
+ )
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/effect/skeleton/ShimmerBrush.kt b/feature/curation/src/main/java/com/example/curation/ui/effect/skeleton/ShimmerBrush.kt
new file mode 100644
index 00000000..35fccf5e
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/effect/skeleton/ShimmerBrush.kt
@@ -0,0 +1,58 @@
+package com.example.curation.ui.effect.skeleton
+
+import androidx.compose.animation.core.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+
+@Composable
+private fun shimmerAnimation(): Float {
+ val transition = rememberInfiniteTransition(label = "shimmer")
+
+ val translate by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1000f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(
+ durationMillis = 1200,
+ easing = LinearEasing
+ ),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "shimmerTranslate"
+ )
+ return translate
+}
+
+/** 기본 그레이 쉬머 */
+@Composable
+fun grayShimmerBrush(): Brush {
+ val translate = shimmerAnimation()
+
+ return Brush.linearGradient(
+ colors = listOf(
+ Color(0xFFF4F5F7),
+ Color(0xFFE9EAEE),
+ Color(0xFFF4F5F7)
+ ),
+ start = Offset(translate - 300f, 0f),
+ end = Offset(translate, 0f)
+ )
+}
+
+/** 밝은 핑크 쉬머 */
+@Composable
+fun pinkShimmerBrush(): Brush {
+ val translate = shimmerAnimation()
+
+ return Brush.linearGradient(
+ colors = listOf(
+ Color(0xFFFBEEFF),
+ Color(0xFFE3A3F5),
+ Color(0xFFFBEEFF)
+ ),
+ start = Offset(translate - 300f, 0f),
+ end = Offset(translate, 0f)
+ )
+}
diff --git a/feature/curation/src/main/java/com/example/curation/ui/effect/skeleton/SkeletonBox.kt b/feature/curation/src/main/java/com/example/curation/ui/effect/skeleton/SkeletonBox.kt
new file mode 100644
index 00000000..e4472b9d
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/effect/skeleton/SkeletonBox.kt
@@ -0,0 +1,58 @@
+package com.example.curation.ui.effect.skeleton
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.RectangleShape
+
+@Composable
+fun SkeletonBox(
+ modifier: Modifier = Modifier,
+ shape: Shape = RectangleShape,
+ shimmerBrush: Brush
+) {
+ Box(
+ modifier = modifier
+ .clip(shape)
+ .background(shimmerBrush)
+ )
+}
+
+/**
+ * 사용법
+ * 카드 스켈레톤(라운드가 있다면?) + 일반 스켈레톤
+ * SkeletonBox(
+ * modifier = Modifier
+ * .size(346.scaler, 432.scaler),
+ * shape = RoundedCornerShape(24.scaler),
+ * shimmerBrush = grayShimmerBrush()
+ * )
+ *
+ *
+ * 핑크 쉬머(큐레이션 디테일 화면에 사용?)
+ * SkeletonBox(
+ * modifier = Modifier
+ * .fillMaxWidth()
+ * .height(180.scaler),
+ * shape = RoundedCornerShape(20.scaler),
+ * shimmerBrush = pinkShimmerBrush()
+ * )
+ *
+ *
+ * //텍스트 라인
+ * SkeletonBox(
+ * modifier = Modifier
+ * .height(14.scaler)
+ * .fillMaxWidth(0.6f),
+ * shape = RoundedCornerShape(4.scaler),
+ * shimmerBrush = grayShimmerBrush()
+ * )
+ *
+ *
+ *
+ *
+ * */
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/item/CurationCheckOutButton.kt b/feature/curation/src/main/java/com/example/curation/ui/item/CurationCheckOutButton.kt
new file mode 100644
index 00000000..4ea09ef8
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/item/CurationCheckOutButton.kt
@@ -0,0 +1,71 @@
+package com.example.curation.ui.item
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.curation.R
+import com.example.design.theme.font.Paperlogy
+
+@Composable
+fun CurationCheckOutButton(
+ onClick: () -> Unit = {},
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .width(106.dp)
+ .height(40.dp)
+ .background(
+ color = Color(0x4DFFFFFF),
+ shape = RoundedCornerShape(size = 23.dp)
+ )
+ .clickable { onClick() }
+ .padding(horizontal = 20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "보러가기",
+ style = TextStyle(
+ fontSize = 13.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(600),
+ color = Color.White
+ )
+ )
+
+ Spacer(modifier = Modifier.width(7.dp))
+
+ Icon(
+ painter = painterResource(id = R.drawable.ic_arrow_right), // 화살표 아이콘
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(12.dp)
+ )
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF1451D5)
+@Composable
+fun PreviewCurationCheckOutButton() {
+ CurationCheckOutButton()
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/list_card/CurationLikedSection.kt b/feature/curation/src/main/java/com/example/curation/ui/list_card/CurationLikedSection.kt
deleted file mode 100644
index c90030ca..00000000
--- a/feature/curation/src/main/java/com/example/curation/ui/list_card/CurationLikedSection.kt
+++ /dev/null
@@ -1,236 +0,0 @@
-package com.example.curation.ui.list_card
-
-
-
-//역할: 사용자가 좋아요한 큐레이션 리스트
-//
-//기능: 세로로 나열된 큐레이션 카드들 렌더링
-//
-//관심사: 데이터 바인딩, 좋아요 표시, 목록 UI
-//큐레이션 메인 페이지(디테일 아님.) 밑에 좋아요 한 리스트까 뜨는 ui임.
-
-
-import androidx.compose.ui.unit.lerp
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-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.res.painterResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.example.curation.ui.UICurationItem
-import com.example.curation.R
-import com.example.curation.Paperlogy
-import androidx.compose.foundation.clickable
-import coil3.compose.rememberAsyncImagePainter
-import com.example.curation.ui.util.rememberScaleFactor
-import com.example.curation.ui.util.shimmer
-
-@Composable
-fun CurationLikedSection(nickname: String) {
-
- val scale = rememberScaleFactor()
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- //.padding(horizontal = (20 * scale).dp)
- ) {
- // 제목
- Text(
- text = "${nickname}님이 좋아요한 큐레이션",
- style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = (20 * scale).sp
- ),
- modifier = Modifier
- .padding(
- start = (24 * scale).dp,
- top = (25 * scale).dp,
- bottom = (18 * scale).dp
- )
- )
-
- Spacer(modifier = Modifier.height((8 * scale).dp))
-
- // 좋아요 리스트 only 프리뷰용.
- val likedCurations = listOf(
- UICurationItem("링큐 큐레이션", "2025년 7월호", R.drawable.img_trump_card, liked = true),
- UICurationItem("링큐 큐레이션", "2025년 6월호", R.drawable.img_trump_card, liked = true),
- UICurationItem("링큐 큐레이션", "2025년 5월호", R.drawable.img_trump_card, liked = true)
- )
-
- LazyColumn(
- modifier = Modifier.fillMaxWidth(),
- contentPadding = PaddingValues(bottom = (24 * scale).dp)
- ) {
- items(likedCurations) { item ->
- LikedCurationCard(
- item = item,
- modifier = Modifier
- .padding(horizontal = (20 * scale).dp)
- )
- Spacer(modifier = Modifier.height((10 * scale).dp))
- }
- }
- }
-}
-
-@Composable
-fun LikedCurationCard(
- item: UICurationItem,
- modifier: Modifier = Modifier,
- onCardClick: (() -> Unit)? = null,
- onHeartClick: (() -> Unit)? = null
-) {
- BoxWithConstraints(
- modifier = modifier
- .fillMaxWidth()
- .clickable(enabled = onCardClick != null) { onCardClick?.invoke() }
- ) {
- val w = maxWidth
- val isTablet = w >= 600.dp
- val scale = rememberScaleFactor()
-
- /** ✔ 카드 높이 반응형 */
- val targetHeight = when {
- w <= 430.dp -> 120.dp // 일반 스마트폰
- w >= 600.dp -> 160.dp // 태블릿
- else -> lerp( // 울트라 등
- 120.dp,
- 160.dp,
- ((w - 430.dp) / (600.dp - 430.dp)).coerceIn(0f, 1f)
- )
- }
-
- val corner = if (isTablet) 18.dp * scale else 18.dp
-
- Box(
- modifier = Modifier
- .height(targetHeight)
- .clip(RoundedCornerShape(corner))
- ) {
- /** 배경 이미지 */
- Image(
- painter = when {
- item.imageUrl?.isNotBlank() == true ->
- rememberAsyncImagePainter(item.imageUrl)
- item.imageRes != null ->
- painterResource(item.imageRes)
- else ->
- painterResource(R.drawable.img_trump_card)
- },
- contentDescription = null,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop
- )
-
- /** ✔ 위치 비율(Figma 기준비 적용) */
- val titleX = w * (26f / 372f)
- val titleY = targetHeight * (54f / 120f)
- val dateY = targetHeight * (80f / 120f)
-
- val gotoX = w * (1f - (22f / 372f))
- val gotoY = targetHeight * (100f / 120f)
-
- /** ✔ 글자 크기: 폰 고정, 태블릿만 Scale */
- val titleFont = if (isTablet) (20 * scale).sp else 20.sp
- val dateFont = if (isTablet) (16 * scale).sp else 16.sp
- val gotoFont = if (isTablet) (13 * scale).sp else 13.sp
-
- /** 제목 */
- Text(
- text = item.title,
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = titleFont,
- color = Color.White,
- modifier = Modifier.offset(titleX, titleY)
- )
-
- /** 날짜 */
- Text(
- text = item.date,
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Medium,
- fontSize = dateFont,
- color = Color.White,
- modifier = Modifier.offset(titleX, dateY)
- )
-
- /** 보러가기 */
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(
- end = if (isTablet) 24.dp * scale else 24.dp,
- bottom = if (isTablet) 16.dp * scale else 16.dp
- )
- .clickable { onCardClick?.invoke() }
- ) {
- Text(
- "보러가기",
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Medium,
- fontSize = gotoFont,
- color = Color.White
- )
-
- Spacer(Modifier.width(6.dp))
-
- Image(
- painter = painterResource(R.drawable.ic_curation_vector),
- contentDescription = null,
- modifier = Modifier.size(if (isTablet) 10.dp * scale else 10.dp)
- )
- }
-
- /** 하트 */
- Icon(
- painter = painterResource(R.drawable.ic_heart),
- contentDescription = null,
- tint = Color.White,
- modifier = Modifier
- .align(Alignment.TopEnd)
- .padding(if (isTablet) 18.dp * scale else 18.dp)
- .size(if (isTablet) 16.dp * scale else 16.dp)
- .clickable { onHeartClick?.invoke() }
- )
- }
- }
-}
-@Composable
-fun LikedCurationSkeleton() {
- val scale = rememberScaleFactor()
-
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height((120 * scale).dp)
- .padding(horizontal = (20 * scale).dp)
- .clip(RoundedCornerShape((18 * scale).dp))
- .shimmer()
- .background(Color(0xFFEDEDED))
- )
-}
-
-@Preview(showBackground = true, showSystemUi = true)
-@Composable
-fun PreviewCurationLikedSection() {
- CurationLikedSection(nickname = "세나님")
-}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationCardItem.kt b/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationCardItem.kt
new file mode 100644
index 00000000..d93fd04f
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationCardItem.kt
@@ -0,0 +1,215 @@
+package com.example.curation.ui.main_card
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil3.compose.SubcomposeAsyncImage
+import com.example.curation.R
+import com.example.design.util.scaler
+import java.time.LocalDate
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.Alignment
+import androidx.compose.material3.Text
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.example.design.theme.font.Paperlogy
+import androidx.compose.foundation.layout.Row
+import com.example.curation.ui.item.CurationCheckOutButton
+
+// 카드 컨텐츠 데이터 클래스(api 연동 아님.)
+data class CurationCardContent(
+ val title: String,
+ val description: String
+)
+
+// 카드별 고정 컨텐츠 (1번은 동적으로 연도/월 계산)
+fun getCurationCardContents(): List {
+ val prevMonth = LocalDate.now().minusMonths(1)
+ val year = prevMonth.year.toString()
+ val month = prevMonth.monthValue
+
+ return listOf(
+ // 현재 기준 바로 직전 지난 달을 보여줌.
+ CurationCardContent(
+ title = "${year}\n월간 큐레이션 ${month}월호",
+ description = "이번 달을 위한 링크, 링큐가 준비했어요"
+ ),
+ CurationCardContent(
+ title = "나와 비슷한 사람들은\n이런 링크를 봤어요",
+ description = "이번 달, 같은 일상을 사는 모두의 관심 키워드"
+ ),
+ CurationCardContent(
+ title = "잊고 있던 '나중에 보기',\n오늘이 그날이에요!",
+ description = "저장만 해두기엔 아까운 링크들이 기다려요"
+ )
+ )
+}
+
+// CurationMainCardPager.kt
+// private 키워드 제거!
+@Composable
+fun CurationCardItem(
+ imageUrl: String?,
+ page: Int = 0,
+ totalPage: Int = 3,
+ onCheckOutClick: () -> Unit = {},
+ modifier: Modifier = Modifier,
+ @DrawableRes fallbackImage: Int = R.drawable.img_curation_example // 테스트용 기본값
+) {
+ val resolvedImageUrl = imageUrl
+ ?.takeIf { it.isNotBlank() && it != "null" }
+ val contents = getCurationCardContents()
+ val content = contents.getOrNull(page) ?: contents[0]
+
+ Box(
+ modifier = modifier
+ .shadow(
+ elevation = 16.dp,
+ //shape = RoundedCornerShape(24.scaler),
+ ambientColor = Color.Black.copy(alpha = 0.4f),
+ spotColor = Color.Black.copy(alpha = 0.4f)
+ )
+ .clip(RoundedCornerShape(24.scaler))
+ .background(Color(0xFFF2F2F2))
+ ) {
+ // 배경 이미지
+ if (resolvedImageUrl == null) {
+ Image(
+ painter = painterResource(id = fallbackImage),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.matchParentSize()
+ )
+ } else {
+ SubcomposeAsyncImage(
+ model = resolvedImageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.matchParentSize()
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(
+ brush = Brush.verticalGradient(
+ colorStops = arrayOf(
+ 0.0f to Color(0x00000000),
+ 1.0f to Color(0x66000000)
+ )
+ )
+ )
+ )
+
+ // 텍스트 오버레이
+ Column(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(top = 262.scaler)
+ ) {
+ // 제목
+ Text(
+ text = content.title,
+ style = TextStyle(
+ fontSize = 24.sp,
+ lineHeight = 30.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(700),
+ color = Color(0xFFFFFFFF),
+ ),
+ modifier = Modifier.padding(start = 35.scaler)
+ )
+
+ Spacer(modifier = Modifier.height(18.scaler))
+
+ // 소개글
+ Text(
+ text = content.description,
+ style = TextStyle(
+ fontSize = 15.sp,
+ lineHeight = 22.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(400),
+ color = Color(0xFFFFFFFF),
+ ),
+ modifier = Modifier.padding(start = 35.scaler)
+ )
+
+ Spacer(modifier = Modifier.height(18.scaler))
+
+ // 01 | 03 + 보러가기 버튼
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ start = 35.scaler,
+ end = 35.scaler
+ )
+ ) {
+ // 페이지 표시
+ Row(
+ modifier = Modifier.align(Alignment.CenterStart),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = String.format("%02d", page + 1),
+ style = TextStyle(
+ fontSize = 14.sp,
+ lineHeight = 16.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(500),
+ color = Color(0xFFD7D9DF),
+ textAlign = TextAlign.Center,
+ )
+ )
+ Text(
+ text = " | ",
+ style = TextStyle(
+ fontSize = 14.sp,
+ lineHeight = 16.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(500),
+ color = Color(0xFFD7D9DF),
+ textAlign = TextAlign.Center,
+ )
+ )
+ Text(
+ text = String.format("%02d", totalPage),
+ style = TextStyle(
+ fontSize = 14.sp,
+ lineHeight = 16.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight(500),
+ color = Color(0xFFD7D9DF),
+ textAlign = TextAlign.Center,
+ )
+ )
+ }
+
+ // 보러가기 버튼
+ CurationCheckOutButton(
+ onClick = onCheckOutClick,
+ modifier = Modifier.align(Alignment.CenterEnd)
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationHighlightSection.kt b/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationHighlightSection.kt
deleted file mode 100644
index 4e72a977..00000000
--- a/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationHighlightSection.kt
+++ /dev/null
@@ -1,288 +0,0 @@
-package com.example.curation.ui.main_card
-
-import androidx.compose.ui.unit.lerp
-import coil3.compose.rememberAsyncImagePainter
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.*
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.example.curation.CurationViewModel
-import com.example.curation.R
-import com.example.curation.Paperlogy
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.graphics.Color
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.compose.ui.platform.LocalInspectionMode
-import com.example.curation.ui.util.rememberScaleFactor
-import com.example.curation.ui.util.shimmer
-
-//디테일 화면 X, 큐레이션 메인 페이지 에 있는 하이라이트 섹션입니다.
-
-
-@Composable
-fun CurationHighlightSection(
- modifier: Modifier = Modifier,
- viewModel: CurationViewModel,
- onOpenDetail: (() -> Unit)? = null
-) {
- val isGenerating by viewModel.isGenerating.collectAsStateWithLifecycle()
- val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle()
- val recentCuration by viewModel.recentCuration.collectAsStateWithLifecycle()
- val highlightLiked by viewModel.highlightLiked.collectAsStateWithLifecycle()
- val likeBusy by viewModel.likeBusy.collectAsStateWithLifecycle()
-
- LaunchedEffect(recentCuration, isGenerating) {
- if (recentCuration == null && !isGenerating) viewModel.loadMonthlyCuration()
- }
-
- val isEmptySuccess = recentCuration == null && !isGenerating && errorMessage == null
-
- when {
- recentCuration != null -> {
- HighlightCurationCard(
- imageUrl = recentCuration!!.thumbnailUrl,
- title = "링큐 큐레이션",
- date = recentCuration!!.month ?: "",
- liked = highlightLiked ?: false,
- likeBusy = likeBusy,
- onClickCard = { onOpenDetail?.invoke() },
- onToggleLike = { viewModel.toggleHighlightLike() },
- modifier = modifier
- )
- }
-
- isGenerating -> {
- HighlightCurationSkeleton(
- modifier = modifier
- ) //스켈레톤 + 쉬머로 변경함.
- //Text(text = "큐레이션 생성 중...", modifier = Modifier.padding(horizontal = 20.dp))
- }
-
- isEmptySuccess -> {
- HighlightCardWithFallback(
- imageRes = R.drawable.img_curation_null,
- modifier = modifier
- )
- }
-
- errorMessage != null -> {
- HighlightCardWithFallback(
- imageRes = R.drawable.img_curation_null,
- modifier = modifier
- )
- }
-
- else -> {
- HighlightCardWithFallback(
- imageRes = R.drawable.img_curation_null,
- modifier = modifier
- )
- }
- }
-}
-
-@Composable
-fun HighlightCurationCard(
- imageUrl: String?,
- title: String,
- date: String,
- liked: Boolean,
- likeBusy: Boolean,
- onClickCard: () -> Unit = {},
- onToggleLike: () -> Unit = {},
- modifier: Modifier = Modifier
-) {
- val isPreview = LocalInspectionMode.current
-
- BoxWithConstraints(
- modifier = modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .clickable { onClickCard() }
- ) {
- val w = maxWidth
- val isTablet = w >= 600.dp
- val scale = rememberScaleFactor()
-
- /** 글자 크기: 폰 고정 / 태블릿 업스케일 */
- val titleFont = if (isTablet) (22 * scale).sp else 22.sp
- val dateFont = if (isTablet) (16 * scale).sp else 16.sp
- val gotoFont = if (isTablet) (13 * scale).sp else 13.sp
-
- val heartPadDp = if (isTablet) 18.dp * scale else 18.dp
- val corner = if (isTablet) 12.dp * scale else 12.dp
-
- /** 카드 높이 반응형 */
- val targetHeight = when {
- w <= 430.dp -> 210.dp
- w >= 600.dp -> 260.dp
- else -> lerp(
- 210.dp,
- 260.dp,
- ((w - 430.dp) / (600.dp - 430.dp)).coerceIn(0f, 1f)
- )
- }
-
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(targetHeight)
- ) {
- val cardW = w
- val cardH = targetHeight
-
- val titleX = cardW * (26f / 372f)
- val titleY = cardH * (140f / 210f)
-
- val dateX = cardW * (26f / 372f)
- val dateY = cardH * (170f / 210f)
-
- val gotoY = cardH * (171f / 210f)
- val gotoX = cardW * (1f - (34f + 68f) / 372f)
-
- /** 배경 이미지 */
- Image(
- painter = if (isPreview || imageUrl == null)
- painterResource(R.drawable.img_trump_card_main)
- else rememberAsyncImagePainter(imageUrl),
- contentDescription = null,
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize()
- )
-
- /** 하트 */
- Box(
- modifier = Modifier
- .align(Alignment.TopEnd)
- .padding(heartPadDp)
- .size(
- width = if (isTablet) 20.dp * scale else 20.dp,
- height = if (isTablet) 20.dp * scale else 20.dp //일단 다인언니랑 키워 봄!
- )
-// .size(
-// width = if (isTablet) 16.dp * scale else 16.dp,
-// height = if (isTablet) 15.dp * scale else 15.dp
-// )
- .clickable(enabled = !likeBusy) { onToggleLike() }
- ) {
- Image(
- painter = painterResource(
- if (liked) R.drawable.ic_heart else R.drawable.ic_heart_outline
- ),
- contentDescription = null,
- modifier = Modifier.fillMaxSize()
- )
- }
-
- /** 제목 */
- Text(
- text = title,
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = titleFont,
- color = Color.White,
- modifier = Modifier.offset(titleX, titleY)
- )
-
- /** 날짜 */
- val displayDate = if (date.matches(Regex("\\d{4}-\\d{2}"))) {
- val y = date.substring(0, 4)
- val m = date.substring(5, 7).toInt()
- "${y}년 ${m}월호" // ← 공백 하나 직접 삽입
- } else date
-
- Text(
- text = displayDate,
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Bold,
- fontSize = dateFont,
- color = Color.White,
- modifier = Modifier.offset(dateX, dateY)
- )
-
- /** 보러가기 */
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .offset(gotoX + (22.dp * if (isTablet) scale else 1f), gotoY)
- .clickable { onClickCard() }
- ) {
- Text(
- text = "보러가기",
- fontFamily = Paperlogy,
- fontWeight = FontWeight.Medium,
- fontSize = gotoFont,
- color = Color.White
- )
-
- Spacer(modifier = Modifier.width(6.dp * (if (isTablet) scale else 1f)))
-
- Image(
- painter = painterResource(R.drawable.ic_curation_vector),
- contentDescription = null,
- modifier = Modifier.size(10.dp * (if (isTablet) scale else 1f))
- )
- }
- }
- }
-}
-@Composable
-fun HighlightCardWithFallback(
- imageRes: Int,
- modifier: Modifier = Modifier
-) {
- val scale = rememberScaleFactor()
-
- Box(
- modifier = modifier
- .fillMaxWidth()
- .padding(top = (16 * scale).dp)
- .clip(RoundedCornerShape((12 * scale).dp))
- .aspectRatio(16f / 9f)
- ) {
- Image(
- painter = painterResource(imageRes),
- contentDescription = null,
- contentScale = ContentScale.Fit,
- modifier = Modifier.fillMaxSize()
- )
- }
-}
-
-@Composable
-fun HighlightCurationSkeleton(modifier: Modifier = Modifier) {
- val scale = rememberScaleFactor()
-
- Box(
- modifier = modifier
- .fillMaxWidth()
- .aspectRatio(372f / 210f) // 실제 카드와 동일 비율
- .clip(RoundedCornerShape((12 * scale).dp))
- .shimmer()
- .background(Color(0xFFEDEDED))
- )
-}
-
-@Preview(showBackground = true, widthDp = 390)
-@Composable
-fun PreviewHighlight() {
- HighlightCurationCard(
- imageUrl = null,
- title = "링큐 큐레이션",
- date = "2025-08",
- liked = true,
- likeBusy = false
- )
-}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationMainCard.kt b/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationMainCard.kt
new file mode 100644
index 00000000..72520488
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationMainCard.kt
@@ -0,0 +1,64 @@
+package com.example.curation.ui.main_card
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+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.tooling.preview.Preview
+import com.example.curation.R
+import coil3.compose.SubcomposeAsyncImage
+import com.example.design.util.scaler
+import androidx.compose.foundation.Image
+import androidx.compose.ui.res.painterResource
+/**
+ * 큐레이션 메인 카드
+ *
+ * - Backend 이미지 URL 표시
+ * - imageUrl == null → 예시 이미지 표시
+ * - 사이즈: 346 x 432 (scaler)
+ */
+@Composable
+fun CurationMainCard(
+ imageUrl: String?,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .size(
+ width = 346.scaler,
+ height = 432.scaler
+ )
+ .clip(RoundedCornerShape(24.scaler))
+ .background(Color(0xFFF2F2F2))
+ ) {
+ if (imageUrl.isNullOrBlank()) {
+ // null / preview / fallback //ui 확인을 위해 일단 큐레이션 기본 이미지 보여주도록 수정함. 이후 기본 이미지 확정시 수정함.
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_example),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.matchParentSize()
+ )
+ } else {
+ // 실제 백엔드에서 받아온 이미지
+ SubcomposeAsyncImage(
+ model = imageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.matchParentSize()
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PreviewCurationMainCard() {
+ CurationMainCard(imageUrl = null)
+}
+
diff --git a/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationMainCardPager.kt b/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationMainCardPager.kt
new file mode 100644
index 00000000..e95be301
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/main_card/CurationMainCardPager.kt
@@ -0,0 +1,147 @@
+package com.example.curation.ui.main_card
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil3.compose.SubcomposeAsyncImage
+import com.example.curation.R
+import com.example.design.util.scaler
+import kotlin.math.absoluteValue
+import androidx.compose.animation.core.snap
+
+
+/**
+ * 큐레이션 메인 카드 페이저
+ * - 중앙: 346 x 432
+ * - 양옆: 137.5 x 230
+ * - 간격: 12dp
+ */
+@Composable
+fun CurationMainCardPager(
+ pagerState: PagerState,
+ imageUrls: List, // 3개의 이미지 URL
+ isDetailOpen: Boolean,
+ modifier: Modifier = Modifier,
+ onCardClick: (index: Int, imageUrl: String?) -> Unit = { _, _ -> } // 추가
+) {
+
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ userScrollEnabled = !isDetailOpen,
+ contentPadding = PaddingValues(horizontal = 33.scaler), // 양옆 여백으로 미리보기
+ pageSpacing = 12.scaler,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(432.scaler)
+ ) { page ->
+ val pageOffset = (
+ (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
+ ).absoluteValue
+
+ // 0 = 중앙(선택), 1 = 양옆
+ val isSelected = pageOffset < 0.5f
+
+ // 애니메이션으로 크기 전환
+ val cardWidth by animateDpAsState(
+ targetValue = if (isSelected) 346.scaler else 288.scaler,
+ animationSpec = if (isDetailOpen) snap() else tween(300),
+ label = "cardWidth"
+ )
+
+ val cardHeight by animateDpAsState(
+ targetValue = if (isSelected) 432.scaler else 360.scaler,
+ animationSpec = if (isDetailOpen) snap() else tween(300),
+ label = "cardHeight"
+ )
+
+
+ // 스케일 기반 부드러운 전환 (선택사항)
+ val scale = 1f - (pageOffset * 0.3f).coerceIn(0f, 0.3f)
+
+ Box(
+ modifier = Modifier
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center
+ ) {
+ CurationCardItem(
+ imageUrl = imageUrls[page],
+ page = page,
+ totalPage = imageUrls.size,
+ onCheckOutClick = { onCardClick(page, imageUrls[page]) }, // ← 추가
+ modifier = Modifier
+ .width(cardWidth)
+ .height(cardHeight)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(18.scaler))
+
+ // 인디케이터
+ CurationPagerIndicator(
+ pageCount = imageUrls.size,
+ currentPage = pagerState.currentPage
+ )
+ }
+}
+
+
+@Composable
+private fun CurationPagerIndicator(
+ pageCount: Int,
+ currentPage: Int,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(7.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ repeat(pageCount) { index ->
+ val isSelected = index == currentPage
+
+ Box(
+ modifier = Modifier
+ .width(if (isSelected) 28.dp else 7.dp)
+ .height(7.dp)
+ .background(
+ color = if (isSelected) Color(0xFF87898F) else Color(0xFFD7D9DF),
+ shape = RoundedCornerShape(if (isSelected) 3.5.dp else 7.dp)
+ )
+ )
+ }
+ }
+}
+
+/*
+@Preview(showBackground = true)
+@Composable
+private fun PreviewCurationMainCardPager() {
+ CurationMainCardPager(
+ imageUrls = listOf(null, null, null)
+ )
+}*/
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/recommend_list/CurationRecommendedLinksSection.kt b/feature/curation/src/main/java/com/example/curation/ui/recommend_list/CurationRecommendedLinksSection.kt
index b53a7c2c..93601b3d 100644
--- a/feature/curation/src/main/java/com/example/curation/ui/recommend_list/CurationRecommendedLinksSection.kt
+++ b/feature/curation/src/main/java/com/example/curation/ui/recommend_list/CurationRecommendedLinksSection.kt
@@ -39,10 +39,10 @@ import com.example.curation.ui.resolveSourceLabel
import com.example.core.model.RecommendedLink
import com.example.curation.R
import coil3.request.crossfade
-import com.example.curation.Paperlogy
import com.example.curation.ui.util.ShimmerSkeleton
import com.example.curation.ui.util.rememberScaleFactor
import com.example.curation.ui.util.shimmer
+import com.example.design.theme.font.Paperlogy
import java.net.URI
@@ -229,7 +229,7 @@ fun RecommendedLinkCard(
Text(
text = link.title,
style = MaterialTheme.typography.bodyLarge.copy(
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
lineHeight = 22.sp,
fontWeight = FontWeight(500),
fontSize = titleSize
@@ -276,7 +276,7 @@ fun RecommendedLinkCard(
Text(
text = sourceLabel,
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontWeight = FontWeight(600),
fontSize = sourceSize,
color = Color(0xFF43454B)
@@ -305,7 +305,7 @@ fun TagChip(text: String) {
style = TextStyle(
fontSize = 10.sp,
lineHeight = 12.sp,
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontWeight = FontWeight(500),
color = Color(0xFF87898F)
),
diff --git a/feature/curation/src/main/java/com/example/curation/ui/CurationDetailScreen.kt b/feature/curation/src/main/java/com/example/curation/ui/screen/CurationDetailScreen.kt
similarity index 92%
rename from feature/curation/src/main/java/com/example/curation/ui/CurationDetailScreen.kt
rename to feature/curation/src/main/java/com/example/curation/ui/screen/CurationDetailScreen.kt
index 3c51aac4..38ddf4b6 100644
--- a/feature/curation/src/main/java/com/example/curation/ui/CurationDetailScreen.kt
+++ b/feature/curation/src/main/java/com/example/curation/ui/screen/CurationDetailScreen.kt
@@ -1,20 +1,16 @@
-package com.example.curation.ui
+package com.example.curation.ui.screen
import androidx.compose.ui.geometry.Offset
import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.Image
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -32,50 +28,33 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
-import com.example.curation.Paperlogy
import com.example.design.theme.LocalColorTheme
import com.example.curation.CurationDetailViewModel
-import com.example.curation.R
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.Dp
import com.example.core.model.RecommendedLink
import com.example.curation.CurationLinksUiState
-import kotlin.math.ceil
import kotlin.math.min
import com.example.curation.CurationViewModel
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.offset
-import androidx.compose.ui.platform.LocalContext
-import coil3.compose.AsyncImage
-import coil3.request.ImageRequest
-import coil3.request.crossfade
import androidx.compose.runtime.*
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.TextStyle
import com.example.curation.CurationDetailUiState
-import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.core.util.ensureHttpScheme
import com.example.curation.ui.detail_card.HighlightCard
import com.example.curation.ui.recommend_list.CurationRecommendedLinksSection
import com.example.curation.ui.recommend_list.RecommendedLinkCardSkeleton
import com.example.curation.ui.recommend_list.SkeletonEnd
import com.example.curation.ui.recommend_list.SkeletonStart
-import kotlinx.coroutines.delay
+import com.example.design.theme.font.Paperlogy
//헬퍼
@@ -122,8 +101,8 @@ fun CurationDetailScreen(
}
val monthLabel = remember {
- java.time.LocalDate.now()
- .format(java.time.format.DateTimeFormatter.ofPattern("M월", java.util.Locale.KOREAN))
+ LocalDate.now()
+ .format(DateTimeFormatter.ofPattern("M월", Locale.KOREAN))
}
CurationDetailScreenContent(
@@ -195,7 +174,7 @@ private fun CurationDetailScreenContent(
Text(
text = "추천 링크",
style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
),
@@ -366,7 +345,7 @@ fun TagChip(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontSize = 12.sp,
color = Color.Gray
)
@@ -403,7 +382,7 @@ private fun PositiveNoteCard(
style = TextStyle(
fontSize = 14.sp,
lineHeight = 20.sp,
- fontFamily = Paperlogy,
+ fontFamily = Paperlogy.font,
fontWeight = FontWeight(400),
color = Color(0xFF43454B)
)
diff --git a/feature/curation/src/main/java/com/example/curation/ui/screen/detail/CurationMonthDetailOverlay.kt b/feature/curation/src/main/java/com/example/curation/ui/screen/detail/CurationMonthDetailOverlay.kt
new file mode 100644
index 00000000..7aafa1d3
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/screen/detail/CurationMonthDetailOverlay.kt
@@ -0,0 +1,158 @@
+package com.example.curation.ui.screen.detail
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.lerp
+import coil3.compose.SubcomposeAsyncImage
+import com.example.curation.R
+import com.example.curation.ui.util.CurationConstants
+import com.example.design.util.scaler
+import kotlinx.coroutines.delay
+
+
+
+@Composable
+fun CurationMonthDetailOverlay(
+ page: Int,
+ imageUrl: String?,
+ onBack: () -> Unit
+) {
+ var animationState by remember { mutableStateOf(null) }
+ var shouldClose by remember { mutableStateOf(false) }
+
+ val animationProgress by animateFloatAsState(
+ targetValue = when (animationState) {
+ true -> 1f
+ false -> 0f
+ null -> 0f
+ },
+ animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
+ label = "progress",
+ finishedListener = { finalValue ->
+ if (finalValue == 0f && animationState == false) {
+ shouldClose = true
+ }
+ }
+ )
+
+ val contentProgress by animateFloatAsState(
+ targetValue = when (animationState) {
+ true -> 1f
+ else -> 0f
+ },
+ animationSpec = tween(
+ durationMillis = 250,
+ delayMillis = if (animationState == true) 100 else 0,
+ easing = FastOutSlowInEasing
+ ),
+ label = "contentProgress"
+ )
+
+ LaunchedEffect(Unit) {
+ delay(20)
+ animationState = true
+ }
+
+ LaunchedEffect(shouldClose) {
+ if (shouldClose) {
+ onBack()
+ }
+ }
+
+ BackHandler {
+ if (animationState == true) {
+ animationState = false
+ }
+ }
+
+ // 크기/위치 계산
+ val startHeight = CurationConstants.CARD_HEIGHT_VALUE.scaler
+ val endHeight = CurationConstants.DETAIL_CARD_HEIGHT_VALUE.scaler
+ val listCardTopOffset = CurationConstants.CARD_TOP_OFFSET_VALUE.scaler
+ val startPadding = CurationConstants.CARD_HORIZONTAL_PADDING_VALUE.scaler
+
+ val horizontalPadding = lerp(startPadding, 0.scaler, animationProgress)
+ val cardHeight = lerp(startHeight, endHeight, animationProgress)
+ val topOffset = lerp(listCardTopOffset, 0.scaler, animationProgress)
+
+ val resolvedImageUrl = imageUrl?.takeIf { it.isNotBlank() && it != "null" }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+
+ // 배경 오버레이
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White.copy(alpha = animationProgress * 0.97f))
+ )
+
+ // 카드 이미지
+ Box(
+ modifier = Modifier
+ .offset(y = topOffset)
+ .fillMaxWidth()
+ .padding(horizontal = horizontalPadding)
+ .height(cardHeight)
+ .clip(RoundedCornerShape(24.scaler))
+ .background(Color(0xFFF2F2F2))
+ ) {
+
+ if (resolvedImageUrl == null) {
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_example),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ SubcomposeAsyncImage(
+ model = resolvedImageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+
+ // 상세 콘텐츠
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = endHeight)
+ .offset(y = 60.scaler * (1f - contentProgress))
+ .graphicsLayer { alpha = contentProgress }
+ .background(Color.White)
+ ) {
+ CurationMonthDetailContent(page = page)
+
+ }
+ }
+}
+
+@Composable
+private fun CurationMonthDetailContent(page: Int) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ .padding(horizontal = 20.scaler)
+ ) {
+ Spacer(modifier = Modifier.height(20.scaler))
+ Text(
+ text = "큐레이션 상세 화면 #$page",
+ color = Color.Black
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/screen/detail/CurationMonthDetailScreen.kt b/feature/curation/src/main/java/com/example/curation/ui/screen/detail/CurationMonthDetailScreen.kt
new file mode 100644
index 00000000..1e4922ef
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/screen/detail/CurationMonthDetailScreen.kt
@@ -0,0 +1,183 @@
+// CurationMonthDetailScreen.kt
+package com.example.curation.ui.screen.detail
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.lerp
+import coil3.compose.SubcomposeAsyncImage
+import com.example.curation.R
+import com.example.curation.ui.main_card.CurationCardItem
+import com.example.design.util.scaler
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import kotlinx.coroutines.delay
+
+@Composable
+fun CurationMonthDetailScreen(
+ curationId: Long,
+ imageUrl: String?,
+ onBack: () -> Unit
+) {
+ val systemUiController = rememberSystemUiController()
+
+ // 애니메이션 상태: null = 초기, true = 확장, false = 축소(뒤로가기)
+ var animationState by remember { mutableStateOf(null) }
+
+ // 뒤로가기 완료 플래그
+ var shouldNavigateBack by remember { mutableStateOf(false) }
+
+ // 애니메이션 진행률 (0 = 리스트 상태, 1 = 디테일 상태)
+ val animationProgress by animateFloatAsState(
+ targetValue = when (animationState) {
+ true -> 1f
+ false -> 0f
+ null -> 0f
+ },
+ animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
+ label = "progress",
+ finishedListener = { finalValue ->
+ if (finalValue == 0f && animationState == false) {
+ shouldNavigateBack = true
+ }
+ }
+ )
+
+ // 콘텐츠 슬라이드 애니메이션
+ val contentProgress by animateFloatAsState(
+ targetValue = when (animationState) {
+ true -> 1f
+ else -> 0f
+ },
+ animationSpec = tween(
+ durationMillis = 250,
+ delayMillis = if (animationState == true) 100 else 0,
+ easing = FastOutSlowInEasing
+ ),
+ label = "contentProgress"
+ )
+
+ // 진입 시 확장 애니메이션 시작
+ LaunchedEffect(Unit) {
+ delay(20)
+ animationState = true
+ }
+
+ // 실제 뒤로가기 처리
+ LaunchedEffect(shouldNavigateBack) {
+ if (shouldNavigateBack) {
+ onBack()
+ }
+ }
+
+ // StatusBar 설정
+ SideEffect {
+ systemUiController.setStatusBarColor(
+ color = Color.Transparent,
+ darkIcons = true
+ )
+ }
+
+ // 뒤로가기 처리 (애니메이션 역재생)
+ BackHandler {
+ if (animationState == true) {
+ animationState = false
+ }
+ }
+
+ // 크기/위치 계산
+ val startHeight = 432.scaler
+ val endHeight = 350.scaler
+
+ val horizontalPadding = lerp(33.scaler, 0.scaler, animationProgress)
+ val cardHeight = lerp(startHeight, endHeight, animationProgress)
+
+ val listCardTopOffset = 180.scaler
+ val topOffset = lerp(listCardTopOffset, 0.scaler, animationProgress)
+
+ // 배경 알파 - 뒤로가기 시 서서히 투명해짐
+ val backgroundAlpha = animationProgress
+
+ val resolvedImageUrl = imageUrl
+ ?.takeIf { it.isNotBlank() && it != "null" }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+
+ // 배경 오버레이 (서서히 나타났다 사라짐)
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White.copy(alpha = animationProgress))
+ )
+
+ // 카드 이미지 영역
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = horizontalPadding)
+ .offset(y = topOffset)
+ .height(cardHeight)
+ .clip(RoundedCornerShape(24.scaler))
+ .background(Color(0xFFF2F2F2))
+ ) {
+ if (resolvedImageUrl == null) {
+ Image(
+ painter = painterResource(id = R.drawable.img_curation_example),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ SubcomposeAsyncImage(
+ model = resolvedImageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+
+ // 상세 콘텐츠 (아래에서 위로 슬라이드 + 페이드)
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = endHeight)
+ .offset(y = 60.scaler * (1f - contentProgress))
+ .graphicsLayer { alpha = contentProgress }
+ .background(Color.White)
+ ) {
+ CurationMonthDetailContent(curationId = curationId)
+ }
+ }
+}
+
+
+@Composable
+private fun CurationMonthDetailContent(
+ curationId: Long
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.scaler)
+ ) {
+ Spacer(modifier = Modifier.height(20.scaler))
+
+ Text(
+ text = "큐레이션 상세 화면 #$curationId",
+ color = Color.Black
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/top/bar/CurationTopBar.kt b/feature/curation/src/main/java/com/example/curation/ui/top/bar/CurationTopBar.kt
deleted file mode 100644
index 469bfddf..00000000
--- a/feature/curation/src/main/java/com/example/curation/ui/top/bar/CurationTopBar.kt
+++ /dev/null
@@ -1,206 +0,0 @@
-//package com.example.curation.ui.top_bar
-//
-//import androidx.compose.foundation.background
-//import androidx.compose.foundation.border
-//import androidx.compose.foundation.layout.*
-//import androidx.compose.foundation.shape.RoundedCornerShape
-//import androidx.compose.material3.Icon
-//import androidx.compose.material3.Text
-//import androidx.compose.runtime.Composable
-//import androidx.compose.ui.Alignment
-//import androidx.compose.ui.Modifier
-//import androidx.compose.ui.draw.clip
-//import androidx.compose.ui.graphics.Brush
-//import androidx.compose.ui.res.painterResource
-//import androidx.compose.ui.text.SpanStyle
-//import androidx.compose.ui.text.buildAnnotatedString
-//import androidx.compose.ui.text.font.FontWeight
-//import androidx.compose.ui.text.withStyle
-//import androidx.compose.ui.tooling.preview.Preview
-//import androidx.compose.ui.unit.dp
-//import androidx.compose.ui.unit.sp
-//import com.example.design.theme.font.Paperlogy
-//import com.example.design.modifier.noRippleClickable
-//import com.example.design.theme.LocalColorTheme
-//import com.example.design.R as Res
-//import com.example.design.theme.font.Taebaek
-//import com.example.design.util.scaler
-//import androidx.compose.ui.graphics.Color
-//
-///*
-// 공통 TopBar 컴포넌트
-// *
-// * @param modifier Modifier
-// * @param showSearchBar 검색바 표시 여부 (false면 로고+알림만)
-// * @param logoBrush 로고 텍스트 색상/그라데이션 (null이면 테마 기본값)
-// * @param searchBarBrush 검색바 배경 색상/그라데이션 (null이면 테마 기본값)
-// * @param backgroundColor 전체 배경색 (기본값: 흰색, null이면 배경 없음 = 투명)
-// * @param onClickSearch 검색바 클릭 콜백
-// * @param onClickAlarm 알림 아이콘 클릭 콜백
-// */
-//
-//private const val TOPBAR_SIMPLE_HEIGHT = 77.4f // 로고 + 알림만
-//private const val TOPBAR_SEARCH_HEIGHT = 139f // 검색바 포함 //기존 파일은 206
-//
-//private val DEFAULT_BACKGROUND = Color.White // 기본 배경 흰색
-//
-//@Composable
-//fun CurationTopBar(
-// modifier: Modifier = Modifier,
-// showSearchBar: Boolean = true,
-// logoBrush: Brush? = null,
-// searchBarBrush: Brush? = null,
-// searchBarBorderColor: Color? = null,
-// backgroundColor: Color? = DEFAULT_BACKGROUND, // 기본값: 흰색, null이면 투명
-// onClickSearch: () -> Unit = {},
-// onClickAlarm: () -> Unit = {}
-//) {
-// //디자인 모듈 불러오기
-// val colorTheme = LocalColorTheme.current
-//
-// // 기본값 설정 - 파일 제외 모두 기본값은 동일합니다.
-// val actualLogoBrush = logoBrush ?: colorTheme.maincolor
-// val actualSearchBarBrush = searchBarBrush ?: colorTheme.maincolor
-// val actualBackgroundColor = backgroundColor ?: colorTheme.white
-//
-// // 로고 + 알림만 있을 때, 탑 바 높이를 77.4.scaler 일반적일 때는 139
-// val topBarHeight =
-// if (showSearchBar) TOPBAR_SEARCH_HEIGHT.scaler
-// else TOPBAR_SIMPLE_HEIGHT.scaler
-//
-// // null이면 배경 없음, 아니면 해당 색상 적용
-// val backgroundModifier = if (backgroundColor != null) {
-// Modifier.background(backgroundColor)
-// } else {
-// Modifier // 배경 없음 (투명)
-// }
-//
-// // 파일 탭과 동일한 규격이나 반응형으로 수정함.
-// Box(
-// modifier = modifier
-// .fillMaxWidth()
-// .height(topBarHeight)
-// .then(backgroundModifier)
-// ) {
-//
-// //링큐 로고 텍스트
-// Text(
-// modifier = Modifier
-// .align(Alignment.TopStart)
-// .padding(start = 35.scaler, top = 52.scaler),
-// text = buildAnnotatedString {
-// withStyle(
-// SpanStyle(
-// fontSize = 24.sp,
-// fontFamily = Taebaek.font,
-// fontWeight = FontWeight(400),
-// brush = actualLogoBrush
-// )
-// ) {
-// append("링큐")
-// }
-// }
-// )
-//
-// // 알림
-// Icon(
-// painter = painterResource(id = Res.drawable.ic_alarm),
-// contentDescription = "알림",
-// tint = colorTheme.gray[300],
-// modifier = Modifier
-// .align(Alignment.TopEnd)
-// .padding(end = 29.8f.scaler, top = 50.38f.scaler)
-// .size(width = 22.26f.scaler, height = 27.18f.scaler)
-// .noRippleClickable { onClickAlarm() }
-// )
-//
-// // 빠른 링크 검색바. (showSearchBar가 true일 때만 표시가 됩니다. 마이페이지 탑바 생성시 참고 부탁드립니다.)
-// if (showSearchBar) {
-// // 테두리 Modifier 조건부 적용
-// val borderModifier = if (searchBarBorderColor != null) {
-// Modifier.border(
-// width = 1.scaler,
-// color = searchBarBorderColor,
-// shape = RoundedCornerShape(18.scaler)
-// )
-// } else {
-// Modifier //테두리 없음.
-// }
-// Box(
-// modifier = Modifier
-// .align(Alignment.TopCenter)
-// .padding(top = 91.scaler, start = 16.scaler, end = 16.scaler)
-// .fillMaxWidth()
-// .height(48.scaler)
-// .clip(RoundedCornerShape(18.scaler))
-// .background(brush = actualSearchBarBrush)
-// .then(borderModifier) //테두리 적용 추가.
-// .noRippleClickable { onClickSearch() },
-// contentAlignment = Alignment.CenterStart
-// ) {
-// Row(
-// verticalAlignment = Alignment.CenterVertically,
-// horizontalArrangement = Arrangement.spacedBy(13.scaler)
-// ) {
-// Icon(
-// painter = painterResource(id = Res.drawable.ic_logo_white),
-// contentDescription = null,
-// tint = colorTheme.white,
-// modifier = Modifier
-// .padding(start = 18.5f.scaler, top = 15.scaler, bottom = 16.scaler)
-// .width(23.97571f.scaler)
-// .height(17f.scaler)
-// )
-//
-// Text(
-// text = "빠른 링크 검색",
-// color = colorTheme.white,
-// fontFamily = Paperlogy.font,
-// fontSize = 16.sp,
-// lineHeight = 20.sp,
-// fontWeight = FontWeight.Medium
-// )
-// }
-// }
-// }
-// }
-//}
-//
-//@Preview(showBackground = true, name = "기본 (검색바 포함)")
-//@Composable
-//fun PreviewCurationTopBar() {
-// CurationTopBar()
-//}
-//
-//@Preview(showBackground = true, name = "로고 + 알림만")
-//@Composable
-//fun PreviewCurationTopBarSimple() {
-// CurationTopBar(showSearchBar = false)
-//}
-//
-//@Preview(showBackground = true, name = "커스텀 컬러 (FileTopBar 스타일 프리뷰)")
-//@Composable
-//fun PreviewCurationTopBarCustom() {
-// val colorTheme = LocalColorTheme.current
-//
-// Box(
-// modifier = Modifier
-// .fillMaxWidth()
-// .height(139.scaler)
-// .background(brush = colorTheme.maincolor)
-// ) {
-// CurationTopBar(
-// backgroundColor = null, // 투명
-//
-// logoBrush = Brush.linearGradient(
-// listOf(Color.White, Color.White)
-// ),
-//
-// searchBarBrush = Brush.linearGradient(
-// listOf(Color(0x26FFFFFF), Color(0x26FFFFFF))
-// ),
-//
-// searchBarBorderColor = Color.White
-// )
-// }
-//}
\ No newline at end of file
diff --git a/feature/curation/src/main/java/com/example/curation/ui/util/CurationConstants.kt b/feature/curation/src/main/java/com/example/curation/ui/util/CurationConstants.kt
new file mode 100644
index 00000000..693c2c0a
--- /dev/null
+++ b/feature/curation/src/main/java/com/example/curation/ui/util/CurationConstants.kt
@@ -0,0 +1,23 @@
+package com.example.curation.ui.util
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * 큐레이션 화면 공통 레이아웃 상수 (dp 값)
+ * 사용 시 .scaler 적용 필요
+ */
+object CurationConstants {
+ // 카드 페이저 위치 (화면 상단에서부터)
+ const val CARD_TOP_OFFSET_VALUE = 174
+
+ // 카드 좌우 패딩
+ const val CARD_HORIZONTAL_PADDING_VALUE = 33
+
+ // 카드 크기
+ const val CARD_WIDTH_VALUE = 346
+ const val CARD_HEIGHT_VALUE = 432
+
+ // 디테일 카드 높이
+ const val DETAIL_CARD_HEIGHT_VALUE = 350
+}
\ No newline at end of file
diff --git a/feature/curation/src/main/res/drawable/ic_arrow_right.xml b/feature/curation/src/main/res/drawable/ic_arrow_right.xml
new file mode 100644
index 00000000..0fc1cc55
--- /dev/null
+++ b/feature/curation/src/main/res/drawable/ic_arrow_right.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/feature/curation/src/main/res/drawable/img_curation_calendar.png b/feature/curation/src/main/res/drawable/img_curation_calendar.png
new file mode 100644
index 00000000..19dc1f3f
Binary files /dev/null and b/feature/curation/src/main/res/drawable/img_curation_calendar.png differ
diff --git a/feature/curation/src/main/res/drawable/img_curation_example.png b/feature/curation/src/main/res/drawable/img_curation_example.png
new file mode 100644
index 00000000..0d110ec6
Binary files /dev/null and b/feature/curation/src/main/res/drawable/img_curation_example.png differ
diff --git a/feature/curation/src/main/res/drawable/img_curation_logo.png b/feature/curation/src/main/res/drawable/img_curation_logo.png
new file mode 100644
index 00000000..91b70276
Binary files /dev/null and b/feature/curation/src/main/res/drawable/img_curation_logo.png differ
diff --git a/feature/curation/src/main/res/drawable/img_curation_title.png b/feature/curation/src/main/res/drawable/img_curation_title.png
new file mode 100644
index 00000000..c8da1b19
Binary files /dev/null and b/feature/curation/src/main/res/drawable/img_curation_title.png differ
diff --git a/feature/home/src/main/java/com/example/home/screen/HomeScreen.kt b/feature/home/src/main/java/com/example/home/screen/HomeScreen.kt
index ee4db036..8feb7a44 100644
--- a/feature/home/src/main/java/com/example/home/screen/HomeScreen.kt
+++ b/feature/home/src/main/java/com/example/home/screen/HomeScreen.kt
@@ -1,5 +1,8 @@
package com.example.home.screen
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -13,6 +16,8 @@ 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.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -20,6 +25,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -32,31 +38,43 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil3.compose.AsyncImage
import com.example.core.model.LinkSimpleInfo
+import com.example.core.system.SystemBarController
import com.example.design.BrushText
import com.example.design.top.search.SearchBarTopSheet
+import com.example.design.modifier.noRippleClickable
import com.example.design.theme.LocalColorTheme
import com.example.design.theme.LocalFontTheme
import com.example.design.theme.color.Basic
-import com.example.design.util.DesignSystemBars
+import com.example.file.ui.theme.FileTopBarLinkUFont
+import com.example.file.ui.theme.MainColor
+import androidx.compose.runtime.DisposableEffect
+import com.example.core.model.SystemBarMode
import com.example.home.HomeViewModel
import com.example.home.R
import com.example.home.component.ClipboardLinkPasteBanner
import com.example.home.component.rememberClipboardUrl
import com.example.home.ui.top.bar.HomeTopBar
import kotlinx.coroutines.launch
+import com.example.design.R as Res
data class LinkItem(
val imageResId: Int?, // 링크 대표 이미지
@@ -124,12 +142,19 @@ fun HomeScreen(
onNavigateToSaveLink: (url: String) -> Unit,
) {
//스플래쉬에서 숨긴 시스템 바 다시 뜨도록
- DesignSystemBars(
- statusBarColor = Color.White,
- navigationBarColor = Color.White,
- darkIcons = true,
- immersive = false
- )
+ val systemBarController =
+ LocalContext.current as? SystemBarController
+ val isPreview = LocalInspectionMode.current
+
+ // Home 진입 시 시스템 바 표시
+ DisposableEffect(Unit) { // systemBarController 대신 Unit 권장
+ if (!isPreview && systemBarController != null) {
+ systemBarController.setSystemBarMode(SystemBarMode.VISIBLE)
+ }
+ onDispose {
+ // 이 화면을 나갈 때의 동작이 필요 없다면 비움..
+ }
+ }
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
diff --git a/feature/login/build.gradle.kts b/feature/login/build.gradle.kts
index ce1c0749..1dfd356e 100644
--- a/feature/login/build.gradle.kts
+++ b/feature/login/build.gradle.kts
@@ -90,6 +90,7 @@ dependencies {
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
+ implementation("androidx.browser:browser:1.7.0")
// SharedPreference
implementation(libs.preference.ktx)
diff --git a/feature/login/src/main/java/com/example/login/LoginApp.kt b/feature/login/src/main/java/com/example/login/LoginApp.kt
deleted file mode 100644
index 49695f37..00000000
--- a/feature/login/src/main/java/com/example/login/LoginApp.kt
+++ /dev/null
@@ -1,252 +0,0 @@
-package com.example.login
-
-import androidx.activity.compose.BackHandler
-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.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.navigation.NavBackStackEntry
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.navigation
-import androidx.navigation.compose.rememberNavController
-import com.example.curation.CurationViewModel
-import com.example.file.FileViewModel
-import com.example.file.viewmodel.folder.state.FolderStateViewModel
-import com.example.login.ui.animation.AnimatedLoginScreen
-import com.example.login.ui.bottom_sheet.TermsAgreementSheet
-import com.example.login.ui.screen.EmailLoginScreen
-import com.example.login.ui.screen.EmailVerificationScreen
-import com.example.login.ui.screen.InterestContentScreen
-import com.example.login.ui.screen.InterestPurposeScreen
-import com.example.login.ui.screen.ResetPasswordScreen
-import com.example.login.ui.screen.SignUpGenderScreen
-import com.example.login.ui.screen.SignUpJobScreen
-import com.example.login.ui.screen.SignUpNicknameScreen
-import com.example.login.ui.screen.SignUpPasswordScreen
-import com.example.login.ui.screen.WelcomeScreen
-import com.example.login.ui.terms.MarketingTermsScreenComposable
-import com.example.login.ui.terms.PrivacyTermsScreenFixed
-import com.example.login.ui.terms.ServiceTermsScreen
-import com.example.login.viewmodel.LoginViewModel
-import com.example.login.viewmodel.SignUpViewModel
-import com.example.home.HomeViewModel
-//import com.example.linku_android.deeplink.DeepLinkHandlerViewModel
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.navigation
-import com.example.login.viewmodel.EmailAuthViewModel
-
-/**
- * 안전하게 auth_graph의 BackStackEntry를 가져오는 확장 함수
- * @param currentEntry 현재 composable의 NavBackStackEntry
- * @return auth_graph의 NavBackStackEntry 또는 null (백스택에 없는 경우)
- * */
-private fun NavHostController.getAuthGraphEntry(
- currentEntry: NavBackStackEntry
-): NavBackStackEntry? {
- return runCatching {
- getBackStackEntry("auth_graph")
- }.getOrNull()
-}
-
-/**
- * parentEntry가 null일 때 로그인 화면으로 안전하게 이동
- * 피드백 반영해서 수정함.
-* */
-@Composable
-private fun NavigateToLoginOnError(navController: NavHostController) {
- LaunchedEffect(Unit) {
- navController.navigate("login") {
- popUpTo("auth_graph") { inclusive = true }
- }
- }
-}
-
-/**
- * Navigation Graph 내에서 부모 엔트리를 안전하게 가져옴.
- * */
-@Composable
-fun rememberAuthParentEntry(
- navController: NavHostController,
- currentEntry: NavBackStackEntry
-): NavBackStackEntry? {
- return remember(currentEntry) {
- try {
- navController.getBackStackEntry("auth_graph")
- } catch (e: Exception) {
- null
- }
- }
-}
-
-@Composable
-fun LoginApp(
- //navController: NavHostController, //꼬일 수 있기에 일단 사용하지 않음.
- onLoginSuccess: () -> Unit,
- loginViewModel: LoginViewModel,
- showNavBar: (Boolean) -> Unit
-) {
- val navController = rememberNavController()
-
- NavHost(
- navController = navController,
- startDestination = "auth_graph"
- ) {
-
- navigation(
- route = "auth_graph",
- startDestination = "login"
- ) {
-
- // 공통 화면 정의용 헬퍼 함수 (내부 중복 제거)
- fun authComposable(
- route: String,
- content: @Composable (NavBackStackEntry) -> Unit // VM을 직접 주입하지 않고 엔트리만 전달
- ) {
- composable(route) { entry ->
- val parentEntry = rememberAuthParentEntry(navController, entry)
- if (parentEntry == null) { //팀장 피드백 반영 수정, 부모 엔트리 없는 경우 화면 없이 로그인으로 보냄.
- NavigateToLoginOnError(navController)
- } else {
- // 정상일 때 화면 그림.
- content(parentEntry)
- }
- }
- }
- // 1. 로그인 화면
- authComposable("login") { parentEntry ->
- val signUpVm: SignUpViewModel = hiltViewModel(parentEntry)
- val skipAnimation = parentEntry.savedStateHandle.get("skip_login_animation") ?: false
-
- LaunchedEffect(skipAnimation) {
- if (skipAnimation) parentEntry.savedStateHandle["skip_login_animation"] = false
- }
-
- AnimatedLoginScreen(
- navigator = navController,
- skipAnimation = skipAnimation,
- onSignUpClick = {
- parentEntry.savedStateHandle["show_terms_sheet"] = true
- navController.navigate("email_login")
- }
- )
- }
-
- // 2. 이메일 로그인 + 약관 바텀시트
- authComposable("email_login") { parentEntry ->
- val signUpVm: SignUpViewModel = hiltViewModel(parentEntry)
- LaunchedEffect(Unit) { showNavBar(false) }
-
- val showTermsSheet by parentEntry.savedStateHandle
- .getStateFlow("show_terms_sheet", false).collectAsStateWithLifecycle()
-
- BackHandler(enabled = showTermsSheet) {
- parentEntry.savedStateHandle["show_terms_sheet"] = false
- }
-
- EmailLoginScreen(
- loginViewModel = loginViewModel,
- navigator = navController,
- onSignUpClick = { parentEntry.savedStateHandle["show_terms_sheet"] = true },
- onLoginSuccess = onLoginSuccess
- )
-
- TermsAgreementSheet(
- navController = navController,
- vm = signUpVm,
- visible = showTermsSheet,
- onClose = { parentEntry.savedStateHandle["show_terms_sheet"] = false },
- onClickTerms = {
- parentEntry.savedStateHandle["show_terms_sheet"] = false
- navController.navigate("terms/service")
- },
- onClickPrivacy = {
- parentEntry.savedStateHandle["show_terms_sheet"] = false
- navController.navigate("terms/privacy")
- },
- onClickMarketing = {
- parentEntry.savedStateHandle["show_terms_sheet"] = false
- navController.navigate("terms/marketing")
- }
- )
- }
-
- // 3. 약관 관련 (반복 로직 처리)
- val termsSteps = listOf(
- "terms/service" to { vm: SignUpViewModel -> vm.setAgreeTerms(true) },
- "terms/privacy" to { vm: SignUpViewModel -> vm.setAgreePrivacy(true) },
- "terms/marketing" to { vm: SignUpViewModel -> vm.setAgreeMarketing(true) }
- )
-
- termsSteps.forEach { (route, agreeAction) ->
- authComposable(route) { parentEntry ->
- val vm: SignUpViewModel = hiltViewModel(parentEntry)
-
- // 반환 타입을 Unit으로 수정 (오류 1, 2, 3 해결)
- val onBack: () -> Unit = {
- parentEntry.savedStateHandle["show_terms_sheet"] = true
- navController.popBackStack()
- }
- BackHandler { onBack() }
-
- when(route) {
- "terms/service" -> ServiceTermsScreen(onBackClicked = onBack, onAgreeClicked = { agreeAction(vm); onBack() })
- "terms/privacy" -> PrivacyTermsScreenFixed(onBackClicked = onBack, onAgreeClicked = { agreeAction(vm); onBack() })
- "terms/marketing" -> MarketingTermsScreenComposable(onBackClicked = onBack, onAgreeClicked = { agreeAction(vm); onBack() })
- }
- }
- }
-
- // 4. 이메일 인증 EmailAuthViewModel 사용
- authComposable("email_verification") { parentEntry ->
- // auth_graph 스코프의 EmailAuthViewModel 인스턴스 생성
- val emailVm: EmailAuthViewModel = hiltViewModel(parentEntry)
- // auth_graph 스코프의 SignUpViewModel 인스턴스 생성 (필요시)
- val signUpVm: SignUpViewModel = hiltViewModel(parentEntry)
-
- BackHandler {
- parentEntry.savedStateHandle["skip_login_animation"] = true
- navController.popBackStack()
- }
-
- EmailVerificationScreen(
- navigator = navController,
- parentEntry = parentEntry,
- viewModel = emailVm, // 파라미터 이름을 viewModel로 수정
- signUpViewModel = signUpVm // SignUpViewModel도 동일한 스코프로 전달
- )
- }
-
- // 5. 회원가입 나머지 단계 (SignUpViewModel 사용)
- authComposable("sign_up_password") { parentEntry ->
- SignUpPasswordScreen(navController, hiltViewModel(parentEntry))
- }
- authComposable("sign_up_nickname") { parentEntry ->
- SignUpNicknameScreen(navController, hiltViewModel(parentEntry))
- }
- authComposable("sign_up_gender") { parentEntry ->
- SignUpGenderScreen(navController, hiltViewModel(parentEntry))
- }
- authComposable("sign_up_job") { parentEntry ->
- SignUpJobScreen(navController, hiltViewModel(parentEntry))
- }
- authComposable("sign_up_purpose") { parentEntry ->
- InterestPurposeScreen(navController, hiltViewModel(parentEntry))
- }
- authComposable("sign_up_interest") { parentEntry ->
- InterestContentScreen(navController, hiltViewModel(parentEntry))
- }
- authComposable("welcome") { parentEntry ->
- WelcomeScreen(navController, hiltViewModel(parentEntry))
- }
-
- composable("reset_password") {
- ResetPasswordScreen(navigator = navController)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/feature/login/src/main/java/com/example/login/LoginScreen.kt b/feature/login/src/main/java/com/example/login/LoginScreen.kt
index bb1f4ec4..a7934b83 100644
--- a/feature/login/src/main/java/com/example/login/LoginScreen.kt
+++ b/feature/login/src/main/java/com/example/login/LoginScreen.kt
@@ -3,6 +3,7 @@ package com.example.login
//피그마에서 스플래쉬 다음으로 나오는 로그인 화면 입니다.
+import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
@@ -30,6 +31,9 @@ import com.example.design.util.DesignSystemBars
import com.example.login.ui.item.SocialLoginButton
import com.example.design.theme.font.Paperlogy
import com.example.design.util.scaler
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.ui.platform.LocalContext
+import com.example.login.constants.ServerConfig
@Composable
@@ -43,6 +47,7 @@ fun LoginScreen(
) {
val colorTheme = LocalColorTheme.current
+ val context = LocalContext.current
// 스플래쉬 다음 화면도 역시 바텀바가 보이지 않도록 함.
DesignSystemBars(
@@ -165,7 +170,12 @@ fun LoginScreen(
backgroundColor = Color(0xFFFEE500),
iconRes = R.drawable.icon_login_kakao,
text = "카카오로 시작하기",
- textColor = Color.Black
+ textColor = Color.Black,
+ onClick = {
+ val url = ServerConfig.KAKAO_LOGIN_URL
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ customTabsIntent.launchUrl(context, Uri.parse(url))
+ }
)
// 네이버
@@ -182,7 +192,12 @@ fun LoginScreen(
borderColor = Color(0xFFE0E0E0),
iconRes = R.drawable.icon_login_google,
text = "구글로 시작하기",
- textColor = Color.Black
+ textColor = Color.Black,
+ onClick = {
+ val url = ServerConfig.GOOGLE_LOGIN_URL
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ customTabsIntent.launchUrl(context, Uri.parse(url))
+ }
)
// 이메일 기존 그대로 유지. //TODO 채윤지 : 서원에게 변경된 otp api 받으면 재연동하기
diff --git a/feature/login/src/main/java/com/example/login/constants/ServerConfig.kt b/feature/login/src/main/java/com/example/login/constants/ServerConfig.kt
new file mode 100644
index 00000000..389dc7b8
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/constants/ServerConfig.kt
@@ -0,0 +1,15 @@
+package com.example.login.constants
+
+/**
+ * 서버 URL 상수 모음
+ *
+ * [02.21] 코드래빗 피드백 반영 - 하드코딩된 URL 분리
+ * TODO: 추후 BuildConfig로 환경별(dev/staging/prod) 분리 예정
+ * build.gradle.kts에 buildConfigField 추가 필요 → 팀장이 결정해주세요.
+ */
+object ServerConfig {
+ private const val BASE_URL = "https://linkuserver.store"
+
+ const val KAKAO_LOGIN_URL = "$BASE_URL/login/kakao"
+ const val GOOGLE_LOGIN_URL = "$BASE_URL/login/google"
+}
diff --git a/feature/login/src/main/java/com/example/login/navigation/LoginApp.kt b/feature/login/src/main/java/com/example/login/navigation/LoginApp.kt
new file mode 100644
index 00000000..456de176
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/navigation/LoginApp.kt
@@ -0,0 +1,433 @@
+package com.example.login.navigation
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.navigation
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.example.login.LoginScreen
+import com.example.login.ui.animation.AnimatedLoginScreen
+import com.example.login.ui.bottom_sheet.TermsAgreementSheet
+import com.example.login.ui.screen.email.EmailLoginScreen
+import com.example.login.ui.screen.email.EmailVerificationScreen
+import com.example.login.ui.screen.email.InterestContentScreen
+import com.example.login.ui.screen.email.InterestPurposeScreen
+import com.example.login.ui.screen.email.ResetPasswordScreen
+import com.example.login.ui.screen.email.SignUpGenderScreen
+import com.example.login.ui.screen.email.SignUpJobScreen
+import com.example.login.ui.screen.email.SignUpNicknameScreen
+import com.example.login.ui.screen.email.SignUpPasswordScreen
+import com.example.login.ui.screen.email.WelcomeScreen
+import com.example.login.ui.screen.social.SocialEntryScreen
+import com.example.login.ui.screen.social.SocialGenderScreen
+import com.example.login.ui.screen.social.SocialInterestScreen
+import com.example.login.ui.screen.social.SocialJobScreen
+import com.example.login.ui.screen.social.SocialNicknameScreen
+import com.example.login.ui.screen.social.SocialPurposeScreen
+import com.example.login.ui.screen.social.WelcomeSocialScreen
+import com.example.login.ui.terms.MarketingTermsScreenComposable
+import com.example.login.ui.terms.PrivacyTermsScreenFixed
+import com.example.login.ui.terms.ServiceTermsScreen
+import com.example.login.viewmodel.EmailAuthViewModel
+import com.example.login.viewmodel.LoginViewModel
+import com.example.login.viewmodel.SignUpViewModel
+import com.example.login.viewmodel.SocialAuthViewModel
+
+@Composable
+fun LoginApp(
+ onLoginSuccess: () -> Unit,
+ loginViewModel: LoginViewModel,
+ showNavBar: (Boolean) -> Unit,
+ initialSocialToken: String? = null,
+) {
+ val navController = rememberNavController()
+
+ // 추가: 소셜 딥링크로 진입한 경우 social_entry로 바로 이동
+ LaunchedEffect(initialSocialToken) {
+ if (!initialSocialToken.isNullOrBlank()) {
+ navController.navigate(
+ "social_entry?socialToken=$initialSocialToken&status=TEMP"
+ ) {
+ popUpTo("auth_graph") { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+
+ NavHost(
+ navController = navController,
+ startDestination = "auth_graph"
+ ) {
+
+ // 이메일 인증으로 로그인하기.
+ navigation(
+ route = "auth_graph",
+ startDestination = "login"
+ ) {
+
+ //authComposable: NavEntryHelper로 분리된 rememberAuthParentEntry 사용
+ fun authComposable(
+ route: String,
+ content: @Composable (NavBackStackEntry) -> Unit
+ ) {
+ composable(route) { entry ->
+ val parentEntry = rememberAuthParentEntry(navController, entry)
+ if (parentEntry == null) {
+ NavigateToLoginOnError(navController) // NavEntryHelper에서 import
+ } else {
+ content(parentEntry)
+ }
+ }
+ }
+
+ // 1. 로그인
+ authComposable("login") { parentEntry ->
+ val skipAnimation =
+ parentEntry.savedStateHandle.get("skip_login_animation") ?: false
+
+ LaunchedEffect(skipAnimation) {
+ if (skipAnimation) parentEntry.savedStateHandle["skip_login_animation"] = false
+ }
+
+ AnimatedLoginScreen(
+ navigator = navController,
+ skipAnimation = skipAnimation,
+ onSignUpClick = {
+ parentEntry.savedStateHandle["show_terms_sheet"] = true
+ navController.navigate("email_login")
+ }
+ )
+ }
+
+ // 2. 이메일 로그인 + 약관 바텀시트
+ authComposable("email_login") { parentEntry ->
+ val signUpVm: SignUpViewModel = hiltViewModel(parentEntry)
+ LaunchedEffect(Unit) { showNavBar(false) }
+
+ val showTermsSheet by parentEntry.savedStateHandle
+ .getStateFlow("show_terms_sheet", false)
+ .collectAsStateWithLifecycle()
+
+ BackHandler(enabled = showTermsSheet) {
+ parentEntry.savedStateHandle["show_terms_sheet"] = false
+ }
+
+ EmailLoginScreen(
+ loginViewModel = loginViewModel,
+ navigator = navController,
+ onSignUpClick = { parentEntry.savedStateHandle["show_terms_sheet"] = true },
+ onLoginSuccess = onLoginSuccess
+ )
+
+ TermsAgreementSheet(
+ navController = navController,
+ vm = signUpVm,
+ visible = showTermsSheet,
+ onClose = { parentEntry.savedStateHandle["show_terms_sheet"] = false },
+ onClickTerms = {
+ parentEntry.savedStateHandle["show_terms_sheet"] = false
+ navController.navigate("terms/service")
+ },
+ onClickPrivacy = {
+ parentEntry.savedStateHandle["show_terms_sheet"] = false
+ navController.navigate("terms/privacy")
+ },
+ onClickMarketing = {
+ parentEntry.savedStateHandle["show_terms_sheet"] = false
+ navController.navigate("terms/marketing")
+ }
+ )
+ }
+
+ // 3. 약관 상세 - 반복되는 부분은 helper로 뺌.
+ val termsSteps = listOf(
+ "terms/service" to { vm: SignUpViewModel -> vm.setAgreeTerms(true) },
+ "terms/privacy" to { vm: SignUpViewModel -> vm.setAgreePrivacy(true) },
+ "terms/marketing" to { vm: SignUpViewModel -> vm.setAgreeMarketing(true) }
+ )
+
+ termsSteps.forEach { (route, agreeAction) ->
+ authComposable(route) { parentEntry ->
+ val vm: SignUpViewModel = hiltViewModel(parentEntry)
+
+ val onBack: () -> Unit = {
+ parentEntry.savedStateHandle["show_terms_sheet"] = true
+ navController.popBackStack()
+ }
+ BackHandler { onBack() }
+
+ when (route) {
+ "terms/service" -> ServiceTermsScreen(
+ onBackClicked = onBack,
+ // agreeAction(vm) 뒤에 세미콜론 제거 → 람다 마지막 식이 Unit이어야 함
+ onAgreeClicked = { agreeAction(vm); onBack() }
+ )
+ "terms/privacy" -> PrivacyTermsScreenFixed(
+ onBackClicked = onBack,
+ onAgreeClicked = { agreeAction(vm); onBack() }
+ )
+ "terms/marketing" -> MarketingTermsScreenComposable(
+ onBackClicked = onBack,
+ onAgreeClicked = { agreeAction(vm); onBack() }
+ )
+ }
+ }
+ }
+
+ // 4. 이메일 인증
+ authComposable("email_verification") { parentEntry ->
+ val emailVm: EmailAuthViewModel = hiltViewModel(parentEntry)
+ val signUpVm: SignUpViewModel = hiltViewModel(parentEntry)
+
+ BackHandler {
+ parentEntry.savedStateHandle["skip_login_animation"] = true
+ navController.popBackStack()
+ }
+
+ EmailVerificationScreen(
+ navigator = navController,
+ parentEntry = parentEntry,
+ viewModel = emailVm,
+ signUpViewModel = signUpVm
+ )
+ }
+
+ // 5. 회원가입 단계
+ authComposable("sign_up_password") { parentEntry ->
+ SignUpPasswordScreen(navController, hiltViewModel(parentEntry))
+ }
+ authComposable("sign_up_nickname") { parentEntry ->
+ SignUpNicknameScreen(navController, hiltViewModel(parentEntry))
+ }
+ authComposable("sign_up_gender") { parentEntry ->
+ SignUpGenderScreen(navController, hiltViewModel(parentEntry))
+ }
+ authComposable("sign_up_job") { parentEntry ->
+ SignUpJobScreen(navController, hiltViewModel(parentEntry))
+ }
+ authComposable("sign_up_purpose") { parentEntry ->
+ InterestPurposeScreen(navController, hiltViewModel(parentEntry))
+ }
+ authComposable("sign_up_interest") { parentEntry ->
+ InterestContentScreen(navController, hiltViewModel(parentEntry))
+ }
+ authComposable("welcome") { parentEntry ->
+ WelcomeScreen(navController, hiltViewModel(parentEntry))
+ }
+
+ composable("reset_password") {
+ ResetPasswordScreen(navigator = navController)
+ }
+ }
+
+ /**
+ * 소셜 로그인 회원가입 순서
+ * 1. 스플래쉬 → 2. 로그인 → 3. 소셜 버튼 선택 → 4. 딥링크 진입
+ * 5. SocialNicknameScreen → 6. Gender → 7. Job → 8. Purpose → 9. Interest
+ * 10. WelcomeSocialScreen → 11. 홈
+ *
+ * social_entry: savedStateHandle → navArgument 방식으로 변경
+ * 백엔드 응답 확정 전 socialToken, status 2개만 우선 적용
+ * TODO: 백엔드 수정 후 navDeepLink + provider/result/accessToken/refreshToken/errorCode 추가
+ *
+ * socialComposable: rememberSocialParentEntry null-safe 처리
+ * null이면 NavigateToLoginOnError 호출 → 이메일 플로우와 동일한 안전 패턴
+ * NavEntryHelper.kt의 rememberSocialParentEntry에 try-catch 추가 필요
+ *
+ * social_welcome: onLoginSuccess 전달
+ * WelcomeSocialScreen에 onLoginSuccess 콜백 전달 → 홈 이동 가능
+ * composable 중첩 버그도 함께 수정
+ */
+ navigation(
+ route = "social_auth_graph",
+ startDestination = "social_entry"
+ ) {
+
+ fun socialComposable(
+ route: String,
+ content: @Composable (parentEntry: NavBackStackEntry, entry: NavBackStackEntry) -> Unit
+ ) {
+ composable(route) { entry ->
+ // rememberSocialParentEntry: try-catch로 안전 처리 (NavEntryHelper.kt)
+ // social_auth_graph가 백스택에 없으면 null → 로그인으로 복귀
+ val parentEntry = rememberSocialParentEntry(navController, entry)
+ if (parentEntry == null) {
+ NavigateToLoginOnError(navController)
+ } else {
+ content(parentEntry, entry)
+ }
+ }
+ }
+
+ // navArgument 방식으로 변경
+ // TODO: 백엔드 수정 후 아래 작업 예정
+ // 1. navDeepLink { uriPattern = "https://linkuserver.store/auth?..." } 추가
+ // 2. provider, result, accessToken, refreshToken, errorCode 파라미터 추가
+ // 3. SocialEntryScreen 파라미터 확장 => 서원이 작업 후 확장.
+ composable(
+ route = "social_entry?socialToken={socialToken}&status={status}",
+ arguments = listOf(
+ navArgument("socialToken") {
+ type = NavType.StringType
+ defaultValue = ""
+ },
+ navArgument("status") {
+ type = NavType.StringType
+ defaultValue = ""
+ }
+ )
+ ) { entry ->
+ val socialToken = entry.arguments?.getString("socialToken") ?: ""
+ val status = entry.arguments?.getString("status") ?: ""
+
+ // status가 비어있으면 잘못된 진입 → 로그인으로 복귀
+ if (status.isBlank()) {
+ LaunchedEffect(Unit) {
+ navController.navigate("login") {
+ popUpTo("social_auth_graph") { inclusive = true }
+ }
+ }
+ return@composable
+ }
+
+ SocialEntryScreen(
+ navController = navController,
+ socialToken = socialToken,
+ status = status,
+ onLoginSuccess = onLoginSuccess
+ )
+ }
+
+ // 약관 게이트
+ socialComposable("social_login_gate") { parentEntry, entry ->
+ val signUpVm: SignUpViewModel = hiltViewModel(parentEntry)
+
+ val showTermsSheet by entry.savedStateHandle
+ .getStateFlow("show_terms_sheet", true)
+ .collectAsStateWithLifecycle()
+
+ BackHandler(enabled = showTermsSheet) {
+ entry.savedStateHandle["show_terms_sheet"] = false
+ }
+
+ LoginScreen(navigator = navController)
+
+ TermsAgreementSheet(
+ navController = navController,
+ vm = signUpVm,
+ visible = showTermsSheet,
+ onClose = { entry.savedStateHandle["show_terms_sheet"] = false },
+ onClickTerms = {
+ entry.savedStateHandle["show_terms_sheet"] = false
+ navController.navigate("social_terms/service")
+ },
+ onClickPrivacy = {
+ entry.savedStateHandle["show_terms_sheet"] = false
+ navController.navigate("social_terms/privacy")
+ },
+ onClickMarketing = {
+ entry.savedStateHandle["show_terms_sheet"] = false
+ navController.navigate("social_terms/marketing")
+ }
+ )
+ }
+
+ // 소셜 약관 상세 (auth_graph의 termsSteps와 동일한 패턴으로 반복 제거)
+ val socialTermsSteps = listOf(
+ "social_terms/service" to { vm: SignUpViewModel -> vm.setAgreeTerms(true) },
+ "social_terms/privacy" to { vm: SignUpViewModel -> vm.setAgreePrivacy(true) },
+ "social_terms/marketing" to { vm: SignUpViewModel -> vm.setAgreeMarketing(true) }
+ )
+
+ socialTermsSteps.forEach { (route, agreeAction) ->
+ socialComposable(route) { parentEntry, _ ->
+ val vm: SignUpViewModel = hiltViewModel(parentEntry)
+
+ val onBack: () -> Unit = {
+ navController.popBackStack()
+ navController.currentBackStackEntry
+ ?.savedStateHandle
+ ?.set("show_terms_sheet", true)
+ }
+ BackHandler { onBack() }
+
+ when (route) {
+ "social_terms/service" -> ServiceTermsScreen(
+ onBackClicked = onBack,
+ onAgreeClicked = { agreeAction(vm); onBack() }
+ )
+ "social_terms/privacy" -> PrivacyTermsScreenFixed(
+ onBackClicked = onBack,
+ onAgreeClicked = { agreeAction(vm); onBack() }
+ )
+ "social_terms/marketing" -> MarketingTermsScreenComposable(
+ onBackClicked = onBack,
+ onAgreeClicked = { agreeAction(vm); onBack() }
+ )
+ }
+ }
+ }
+
+ // 소셜 회원가입 입력 플로우
+ socialComposable("social_nickname") { parentEntry, _ ->
+ val vm: SocialAuthViewModel = hiltViewModel(parentEntry)
+ SocialNicknameScreen(navController, vm)
+ }
+
+ socialComposable("social_gender") { parentEntry, _ ->
+ val vm: SocialAuthViewModel = hiltViewModel(parentEntry)
+ SocialGenderScreen(navController, vm)
+ }
+
+ socialComposable("social_job") { parentEntry, _ ->
+ val vm: SocialAuthViewModel = hiltViewModel(parentEntry)
+ SocialJobScreen(navController, vm)
+ }
+
+ socialComposable("social_purpose") { parentEntry, _ ->
+ val vm: SocialAuthViewModel = hiltViewModel(parentEntry)
+ SocialPurposeScreen(navController, vm)
+ }
+
+ // [수정 3 적용] social_interest: entry -> 명시 + socialComposable 헬퍼 사용
+ // [수정 4 적용] onComplete 콜백 안에서만 API 호출 → 리컴포지션 때 호출 안 됨
+ socialComposable("social_interest") { parentEntry, _ ->
+ val vm: SocialAuthViewModel = hiltViewModel(parentEntry)
+ val socialToken = parentEntry.savedStateHandle.get("socialToken") ?: ""
+
+ SocialInterestScreen(
+ navigator = navController,
+ viewModel = vm,
+ // onComplete은 버튼 클릭 시 1회만 호출 → API 중복 호출 없음
+ onComplete = {
+ vm.completeSocialProfile(
+ socialToken = socialToken,
+ onSuccess = {
+ navController.navigate("social_welcome") {
+ popUpTo("social_auth_graph") { inclusive = true }
+ }
+ }
+ )
+ }
+ )
+ }
+
+ composable("social_welcome") {
+ WelcomeSocialScreen(
+ navigator = navController,
+ onLoginSuccess = onLoginSuccess
+ )
+ }
+ }
+ }
+}
+
+
diff --git a/feature/login/src/main/java/com/example/login/navigation/NavEntryHelper.kt b/feature/login/src/main/java/com/example/login/navigation/NavEntryHelper.kt
new file mode 100644
index 00000000..ab8cab87
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/navigation/NavEntryHelper.kt
@@ -0,0 +1,44 @@
+package com.example.login.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+
+/**
+ * auth_graph 스코프의 NavBackStackEntry를 안전하게 remember.
+ * 백스택에 없으면 null 반환.
+ */
+@Composable
+fun rememberAuthParentEntry(
+ navController: NavHostController,
+ currentEntry: NavBackStackEntry
+): NavBackStackEntry? = remember(currentEntry) {
+ runCatching { navController.getBackStackEntry("auth_graph") }.getOrNull()
+}
+
+/**
+ * social_auth_graph 스코프의 NavBackStackEntry를 remember.
+ * 소셜 그래프 내 모든 composable에서 중복 remember 블록 제거용.
+ */
+@Composable
+fun rememberSocialParentEntry(
+ navController: NavHostController,
+ entry: NavBackStackEntry
+): NavBackStackEntry = remember(entry) {
+ navController.getBackStackEntry("social_auth_graph")
+}
+
+/**
+ * parentEntry가 null일 때 로그인 화면으로 안전하게 이동.
+ * auth_graph 백스택이 비어있는 예외 상황 처리용.
+ */
+@Composable
+fun NavigateToLoginOnError(navController: NavHostController) {
+ LaunchedEffect(Unit) {
+ navController.navigate("login") {
+ popUpTo("auth_graph") { inclusive = true }
+ }
+ }
+}
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/EmailLoginScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/EmailLoginScreen.kt
similarity index 93%
rename from feature/login/src/main/java/com/example/login/ui/screen/EmailLoginScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/EmailLoginScreen.kt
index 9c36140f..93133309 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/EmailLoginScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/EmailLoginScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -20,7 +20,6 @@ import com.example.login.R
import com.example.design.theme.font.Paperlogy
import com.example.login.ui.item.LoginTextField
import androidx.compose.foundation.layout.ime
-import androidx.compose.foundation.layout.navigationBars
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.TextStyle
@@ -30,17 +29,18 @@ import com.example.login.ui.item.PasswordLoginTextField
import com.example.design.modifier.noRippleClickable
import android.util.Patterns
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import com.example.design.theme.LocalColorTheme
-import com.example.design.util.DesignSystemBars
import com.example.login.viewmodel.LoginViewModel
-import com.example.design.util.rememberFigmaDimens
import com.example.design.util.scaler
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.example.login.viewmodel.LoginState
-import com.example.login.viewmodel.LoginErrorType
+import com.example.core.model.SystemBarMode
+import com.example.core.model.auth.LoginState
+import com.example.core.system.SystemBarController
+
@Composable
fun EmailLoginScreen(
@@ -73,13 +73,19 @@ fun EmailLoginScreen(
}
}
- // 로그인 입력 화면부터는 시스템 바 다시 표시
- DesignSystemBars(
- statusBarColor = colorTheme.white,
- navigationBarColor = colorTheme.white,
- darkIcons = true,
- immersive = false
- )
+ val systemBarController =
+ LocalContext.current as? SystemBarController
+ val isPreview = LocalInspectionMode.current
+
+ // 로그인 입력 화면 진입 시 시스템 바 복구
+ DisposableEffect(Unit) { // systemBarController 대신 Unit 권장
+ if (!isPreview && systemBarController != null) {
+ systemBarController.setSystemBarMode(SystemBarMode.VISIBLE)
+ }
+ onDispose {
+ // 이 화면을 나갈 때의 동작이 필요 없음. 비움.
+ }
+ }
var email by remember { mutableStateOf("") }
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/EmailVerificationScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/EmailVerificationScreen.kt
similarity index 98%
rename from feature/login/src/main/java/com/example/login/ui/screen/EmailVerificationScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/EmailVerificationScreen.kt
index 039e0030..a09b2660 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/EmailVerificationScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/EmailVerificationScreen.kt
@@ -1,11 +1,10 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import android.util.Log
import android.util.Patterns
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
@@ -24,6 +23,8 @@ import androidx.navigation.compose.rememberNavController
import com.example.design.theme.font.Paperlogy
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavBackStackEntry
+import com.example.core.model.auth.AuthErrorMessages
+import com.example.core.model.auth.EmailAuthState
import com.example.design.modifier.noRippleClickable
import com.example.login.ui.item.LoginTextField
import com.example.login.ui.item.StepIndicator
@@ -32,8 +33,6 @@ import com.example.login.viewmodel.EmailAuthViewModel
import com.example.login.viewmodel.SignUpViewModel
import com.example.design.theme.LocalColorTheme
import com.example.design.util.scaler
-import com.example.login.viewmodel.AuthErrorMessages
-import com.example.login.viewmodel.EmailAuthState
import java.util.Locale
/**
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/InterestContentScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/InterestContentScreen.kt
similarity index 98%
rename from feature/login/src/main/java/com/example/login/ui/screen/InterestContentScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/InterestContentScreen.kt
index 1877cf9d..d7de3bdc 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/InterestContentScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/InterestContentScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import CircleItem
import androidx.compose.foundation.background
@@ -13,7 +13,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -26,11 +25,12 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.example.design.theme.font.Paperlogy
import androidx.compose.ui.unit.Dp
+import com.example.core.model.auth.Interest
import com.example.design.theme.LocalColorTheme
import com.example.login.ui.item.BottomGradientButton
import com.example.login.ui.item.StepIndicator
import com.example.login.viewmodel.SignUpViewModel
-import com.example.login.viewmodel.Interest
+
/**
* 관심사 선택 화면의 버블 데이터 클래스
*/
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/InterestPurposeScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/InterestPurposeScreen.kt
similarity index 98%
rename from feature/login/src/main/java/com/example/login/ui/screen/InterestPurposeScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/InterestPurposeScreen.kt
index c0a1c4f4..85dc687b 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/InterestPurposeScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/InterestPurposeScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import CircleItem
import androidx.compose.foundation.background
@@ -13,7 +13,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -26,10 +25,10 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.example.design.theme.font.Paperlogy
import androidx.compose.ui.unit.Dp
+import com.example.core.model.auth.Purpose
import com.example.design.theme.LocalColorTheme
import com.example.login.ui.item.StepIndicator
import com.example.login.ui.item.BottomGradientButton
-import com.example.login.viewmodel.Purpose
import com.example.login.viewmodel.SignUpViewModel
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/ResetPasswordScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/ResetPasswordScreen.kt
similarity index 97%
rename from feature/login/src/main/java/com/example/login/ui/screen/ResetPasswordScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/ResetPasswordScreen.kt
index aebe8dc9..f0ddcb71 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/ResetPasswordScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/ResetPasswordScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import android.util.Patterns
import androidx.compose.foundation.Image
@@ -15,7 +15,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
@@ -25,7 +24,6 @@ import com.example.login.R
import com.example.login.ui.item.BottomGradientButton
import com.example.login.ui.item.LoginTextField
import com.example.login.ui.item.ResetPasswordTopHeader
-import com.example.design.util.rememberFigmaDimens
import com.example.login.viewmodel.ResetPasswordViewModel
import com.example.design.theme.LocalColorTheme
import com.example.design.util.scaler
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/SignUpGenderScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpGenderScreen.kt
similarity index 95%
rename from feature/login/src/main/java/com/example/login/ui/screen/SignUpGenderScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/SignUpGenderScreen.kt
index 231f265d..6b6ea244 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/SignUpGenderScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpGenderScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import androidx.compose.foundation.background
@@ -7,24 +7,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
+import com.example.core.model.auth.Gender
import com.example.design.theme.font.Paperlogy
import com.example.login.ui.item.BottomGradientButton
import com.example.login.ui.item.StepIndicator
import com.example.login.ui.item.OptionButton
-import com.example.design.util.rememberFigmaDimens
import com.example.login.viewmodel.SignUpViewModel
import com.example.design.theme.LocalColorTheme
import com.example.design.util.scaler
-import com.example.login.viewmodel.Gender
+
@Composable
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/SignUpJobScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpJobScreen.kt
similarity index 95%
rename from feature/login/src/main/java/com/example/login/ui/screen/SignUpJobScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/SignUpJobScreen.kt
index 02e5b587..f391873d 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/SignUpJobScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpJobScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import androidx.compose.foundation.background
@@ -8,24 +8,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
+import com.example.core.model.auth.Job
import com.example.design.theme.LocalColorTheme
import com.example.design.theme.font.Paperlogy
import com.example.login.ui.item.BottomGradientButton
import com.example.login.ui.item.OptionButton
import com.example.login.ui.item.StepIndicator
-import com.example.design.util.rememberFigmaDimens
import com.example.design.util.scaler
import com.example.login.viewmodel.SignUpViewModel
-import com.example.login.viewmodel.Job
+
@Composable
fun SignUpJobScreen(
navigator: NavHostController,
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/SignUpNicknameScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpNicknameScreen.kt
similarity index 94%
rename from feature/login/src/main/java/com/example/login/ui/screen/SignUpNicknameScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/SignUpNicknameScreen.kt
index f97c09a0..dedde1fa 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/SignUpNicknameScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpNicknameScreen.kt
@@ -1,33 +1,28 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.design.theme.font.Paperlogy
import androidx.navigation.NavHostController
import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.navigation.compose.rememberNavController
import androidx.compose.runtime.*
+import com.example.core.model.auth.NicknameCheckState
import com.example.design.theme.LocalColorTheme
import com.example.login.ui.item.BottomGradientButton
import com.example.login.ui.item.LoginTextField
import com.example.login.ui.item.PasswordRuleItem
import com.example.login.ui.item.StepIndicator
-import com.example.design.util.rememberFigmaDimens
import com.example.design.util.scaler
import com.example.login.ui.item.WrongRuleItem
import com.example.login.viewmodel.SignUpViewModel
-import com.example.login.viewmodel.NicknameCheckState
+
//TODO : 닉네임 매게변수.. -> 사용자 이름
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/SignUpPasswordScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpPasswordScreen.kt
similarity index 97%
rename from feature/login/src/main/java/com/example/login/ui/screen/SignUpPasswordScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/SignUpPasswordScreen.kt
index 23303be8..f9c788e3 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/SignUpPasswordScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpPasswordScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
@@ -10,17 +10,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.design.theme.font.Paperlogy
import androidx.navigation.NavHostController
import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.compose.ui.unit.Dp
import com.example.design.theme.LocalColorTheme
-import com.example.design.util.rememberFigmaDimens
import com.example.design.util.scaler
import com.example.login.ui.item.BottomGradientButton
-import com.example.login.ui.item.LoginTextField
import com.example.login.ui.item.StepIndicator
import com.example.login.ui.item.PasswordRuleItem
import com.example.login.ui.item.PasswordLoginTextField
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/SignUpScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpScreen.kt
similarity index 97%
rename from feature/login/src/main/java/com/example/login/ui/screen/SignUpScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/SignUpScreen.kt
index ca265615..88331dec 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/SignUpScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/SignUpScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import androidx.compose.foundation.background
@@ -11,7 +11,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
@@ -23,7 +22,6 @@ import androidx.compose.ui.unit.sp
import com.example.login.R
import com.example.design.theme.font.Paperlogy
import com.example.design.theme.LocalColorTheme
-import com.example.design.util.rememberFigmaDimens
import com.example.design.util.scaler
//어차피.. 수정되니까.. 리펙X
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/WelcomeScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/email/WelcomeScreen.kt
similarity index 54%
rename from feature/login/src/main/java/com/example/login/ui/screen/WelcomeScreen.kt
rename to feature/login/src/main/java/com/example/login/ui/screen/email/WelcomeScreen.kt
index 6530eadb..b3d63586 100644
--- a/feature/login/src/main/java/com/example/login/ui/screen/WelcomeScreen.kt
+++ b/feature/login/src/main/java/com/example/login/ui/screen/email/WelcomeScreen.kt
@@ -1,4 +1,4 @@
-package com.example.login.ui.screen
+package com.example.login.ui.screen.email
import android.util.Log
import androidx.activity.compose.BackHandler
@@ -34,16 +34,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import com.example.design.theme.LocalColorTheme
-import com.example.design.util.rememberFigmaDimens
import com.example.design.util.scaler
import com.example.login.viewmodel.SignUpViewModel
import android.app.Activity
import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
-import com.example.login.viewmodel.SignUpState
+import com.example.core.model.SystemBarMode
+import com.example.core.model.auth.SignUpState
+import com.example.core.system.SystemBarController
@Composable
fun WelcomeScreen(
@@ -55,26 +58,19 @@ fun WelcomeScreen(
val density = LocalDensity.current
val configuration = LocalConfiguration.current
- //여기만 안드로이드 자체 바텀바 컬러 변경
- val view = LocalView.current
- DisposableEffect(Unit) {
- val activity = view.context as? Activity
- ?: return@DisposableEffect onDispose { }
- val window = activity.window
- val insetsController = WindowCompat.getInsetsController(window, view)
-
- // 기존 값 백업
- val originalNavColor = window.navigationBarColor
- val originalAppearance = insetsController.isAppearanceLightNavigationBars
-
- // WelcomeScreen 진입 시 적용
- window.navigationBarColor = Color(0xFFC800FF).toArgb()
- insetsController.isAppearanceLightNavigationBars = false // 아이콘 흰색
+ // 시스템 바 숨기기 설정
+ val systemBarController = LocalContext.current as? SystemBarController
+ val isPreview = LocalInspectionMode.current
+ DisposableEffect(Unit) {
+ if (!isPreview && systemBarController != null) {
+ systemBarController.setSystemBarMode(SystemBarMode.HIDDEN)
+ }
onDispose {
- // WelcomeScreen 벗어날 때 원복
- window.navigationBarColor = originalNavColor
- insetsController.isAppearanceLightNavigationBars = originalAppearance
+ // WelcomeScreen을 떠날 때 다시 바텀바를 보여줌
+ if (!isPreview && systemBarController != null) {
+ systemBarController.setSystemBarMode(SystemBarMode.VISIBLE)
+ }
}
}
@@ -87,8 +83,17 @@ fun WelcomeScreen(
mutableStateOf(SignUpState.Idle)
}
//val signUpSuccess by signUpViewModel.signUpSuccess.collectAsState()
- var isSignUpRequested by remember { mutableStateOf(false) } //중복 호출 방자용 상태 추가
-
+ // 컴포지션 변경으로 초기화 우려가 있었음. remember -> rememberSaveable으로 수정.
+ var isSignUpRequested by rememberSaveable { mutableStateOf(false) } //중복 호출 방자용 상태 추가
+
+ //화면 진입 시 자동 회원가입 요청
+ LaunchedEffect(Unit) {
+ if (!isSignUpRequested) {
+ isSignUpRequested = true
+ Log.d("WelcomeScreen", "Welcome 진입 → 회원가입 자동 요청")
+ signUpViewModel?.signUp()
+ }
+ }
// 서버 응답 감지
LaunchedEffect(signUpState) {
@@ -138,83 +143,61 @@ fun WelcomeScreen(
)
) {
// 중앙 콘텐츠 (Column)
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally
+ Box(
+ modifier = Modifier.fillMaxSize()
) {
+ // 1. 중앙 콘텐츠 레이어 (로고 및 텍스트)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter) // 상단 기준 정렬 후 offset으로 세밀하게 이동
+ .offset(y = (configuration.screenHeightDp.dp * (394f / 917f)) - (65.scaler / 2)),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_logo_white),
+ contentDescription = "Logo",
+ modifier = Modifier
+ // X축 오프셋은 기존 계산식 유지
+ .offset(x = (160.scaler) - (configuration.screenWidthDp.dp / 2) + (46.scaler))
+ .width(92.scaler)
+ .height(65.scaler),
+ contentScale = ContentScale.Fit
+ )
- // 로고 위치 (394/917)
- Spacer(modifier = Modifier.height((394.scaler)))
- Image(
- painter = painterResource(id = R.drawable.img_logo_white),
- contentDescription = "Logo",
- Modifier
- .offset(x = (160.scaler) - (configuration.screenWidthDp.dp / 2) + (46.scaler)) // 시작 너비 보정
- .width((92.scaler))
- .height((65.scaler)),
- contentScale = ContentScale.Fit
- )
-
- Spacer(modifier = Modifier.height((20.scaler)))
-
- Text(
- text = "링큐에 오신 걸 환영해요!",
- color = colorTheme.white,
- fontSize = 22.sp,
- fontWeight = FontWeight.Bold,
- fontFamily = Paperlogy.font,
- textAlign = TextAlign.Start
- )
+ Spacer(modifier = Modifier.height(20.scaler))
- Spacer(modifier = Modifier.height((16.scaler)))
+ Text(
+ text = "링큐에 오신 걸 환영해요!",
+ color = colorTheme.white,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = Paperlogy.font,
+ textAlign = TextAlign.Center // 피그마와 동일하게 중앙 정렬
+ )
- Text(
- text = "당신을 위한 링크, 링큐가 기억하고 연결해줄게요!",
- color = colorTheme.white,
- fontSize = 16.sp,
- fontFamily = Paperlogy.font,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center
+ Spacer(modifier = Modifier.height(16.scaler))
- )
- }
- // 버튼을 Box의 직접 자식으로 두고, 하단 정렬
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .align(Alignment.BottomCenter)
- .padding(start = (20.scaler), end = (20.scaler), bottom = bottomPadding)
- .height((50.scaler))
- .background(
- Color.White,
- shape = RoundedCornerShape(18.dp)
+ Text(
+ text = "당신을 위한 링크, 링큐가 기억하고 연결해줄게요!",
+ color = colorTheme.white,
+ fontSize = 16.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
)
- .clickable(enabled = !isSignUpRequested) {
- if (!isSignUpRequested) {
- isSignUpRequested = true
- Log.d("WelcomeScreen", "회원가입 API 호출 시도")
- signUpViewModel?.signUp()
- }
- },
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = "로그인 하러가기",
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold,
- style = TextStyle(brush = colorTheme.maincolor),
- fontFamily = Paperlogy.font
- )
+ }
- // 혹시 로딩 표시가 필요하다면?
- /*
- * if (signUpState is SignUpState.Loading) {
- CircularProgressIndicator(
- modifier = Modifier.size(24.dp),
- color = Color(0xFFC800FF),
- strokeWidth = 2.dp
- )
- } else {
+ // 2. 하단 버튼 레이어
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .padding(start = 20.scaler, end = 20.scaler, bottom = bottomPadding)
+ .height(50.scaler)
+ .background(Color.White, shape = RoundedCornerShape(18.dp)),
+ contentAlignment = Alignment.Center
+ ) {
Text(
text = "로그인 하러가기",
fontSize = 16.sp,
@@ -222,10 +205,12 @@ fun WelcomeScreen(
style = TextStyle(brush = colorTheme.maincolor),
fontFamily = Paperlogy.font
)
- }*/
+ }
}
+
+
}
}
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/EmailInputScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/EmailInputScreen.kt
new file mode 100644
index 00000000..7e8338e4
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/EmailInputScreen.kt
@@ -0,0 +1,199 @@
+package com.example.login.ui.screen.social
+
+import android.util.Patterns
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.rememberNavController
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavBackStackEntry
+import com.example.design.theme.font.Paperlogy
+import com.example.design.theme.LocalColorTheme
+import com.example.design.util.scaler
+import com.example.login.ui.item.LoginTextField
+import com.example.login.ui.item.StepIndicator
+import com.example.login.ui.item.BottomGradientButton
+import com.example.login.ui.item.WrongRuleItem
+import com.example.login.viewmodel.SignUpViewModel
+
+/**
+ * 소셜 로그인 후 이메일 입력 화면
+ * - OTP 인증 없이 이메일 형식만 검증
+ * - 형식이 맞으면 다음 단계로 진행
+ */
+@Composable
+fun EmailInputScreen(
+ navigator: NavHostController,
+ parentEntry: NavBackStackEntry,
+ signUpViewModel: SignUpViewModel = hiltViewModel()
+) {
+ BackHandler {
+ parentEntry.savedStateHandle["from_email_input"] = true
+ navigator.popBackStack()
+ }
+
+ var email by remember { mutableStateOf("") }
+
+ val emailValid = remember(email) {
+ email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches()
+ }
+
+ EmailInputScreenContent(
+ email = email,
+ onEmailChange = { email = it },
+ emailValid = emailValid,
+ onNextClick = {
+ signUpViewModel.updateForm {
+ it.copy(email = email.trim())
+ }
+ navigator.navigate("sign_up_password") // 또는 다음 화면 route
+ },
+ onBackClick = {
+ parentEntry.savedStateHandle["from_email_input"] = true
+ navigator.popBackStack()
+ }
+ )
+}
+
+@Composable
+fun EmailInputScreenContent(
+ email: String,
+ onEmailChange: (String) -> Unit,
+ emailValid: Boolean,
+ onNextClick: () -> Unit,
+ onBackClick: () -> Unit
+) {
+ val colorTheme = LocalColorTheme.current
+
+ // 이메일 에러 텍스트
+ val emailErrorText: String? = when {
+ email.isNotBlank() && !Patterns.EMAIL_ADDRESS.matcher(email).matches() ->
+ "이메일 양식이 올바르지 않습니다!"
+ else -> null
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ start = 20.scaler,
+ end = 20.scaler,
+ top = 60.scaler,
+ bottom = 72.scaler
+ ),
+ horizontalAlignment = Alignment.Start
+ ) {
+ // 1단계 인디케이터
+ StepIndicator(
+ currentStep = 1,
+ totalSteps = 3,
+ label = "계정 정보"
+ )
+
+ Spacer(modifier = Modifier.height(36.scaler))
+
+ // 타이틀
+ Text(
+ text = "이메일 주소를 입력해주세요",
+ fontSize = 22.sp,
+ lineHeight = 30.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = Paperlogy.font,
+ color = Color.Black
+ )
+
+ Spacer(modifier = Modifier.height(12.scaler))
+
+ // 서브 타이틀
+ Text(
+ text = "계정 복구 및 알림 수신에 사용됩니다",
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ fontWeight = FontWeight.Normal,
+ fontFamily = Paperlogy.font,
+ color = colorTheme.gray[500]!!
+ )
+
+ Spacer(modifier = Modifier.height(32.scaler))
+
+ // 이메일 입력 필드
+ LoginTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ hint = "이메일 주소를 입력해주세요",
+ enabled = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ // 에러 문구
+ emailErrorText?.let {
+ Spacer(modifier = Modifier.height(10.scaler))
+ WrongRuleItem(
+ text = it,
+ modifier = Modifier.padding(start = 12.scaler) // 20 + 12 = 32
+ )
+ }
+ }
+
+ // 하단 버튼
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Bottom,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ BottomGradientButton(
+ text = "다음",
+ enabled = emailValid,
+ activeGradient = colorTheme.maincolor,
+ inactiveGradient = colorTheme.inactiveColor,
+ onClick = onNextClick
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun EmailInputScreenPreview_Empty() {
+ EmailInputScreenContent(
+ email = "",
+ onEmailChange = {},
+ emailValid = false,
+ onNextClick = {},
+ onBackClick = {}
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun EmailInputScreenPreview_InvalidEmail() {
+ EmailInputScreenContent(
+ email = "linku",
+ onEmailChange = {},
+ emailValid = false,
+ onNextClick = {},
+ onBackClick = {}
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun EmailInputScreenPreview_ValidEmail() {
+ EmailInputScreenContent(
+ email = "test@example.com",
+ onEmailChange = {},
+ emailValid = true,
+ onNextClick = {},
+ onBackClick = {}
+ )
+}
\ No newline at end of file
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/SocialEntryScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialEntryScreen.kt
new file mode 100644
index 00000000..05a268d4
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialEntryScreen.kt
@@ -0,0 +1,63 @@
+package com.example.login.ui.screen.social
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.navigation.NavHostController
+
+/**
+ * SocialEntryScreen
+ *
+ * 소셜 로그인 딥링크 진입 지점
+ *
+ * - UI 없음
+ * - 딥링크로 전달받은 값(socialToken, status)을 해석
+ * - ACTIVE / TEMP 상태에 따라 분기만 수행
+ */
+@Composable
+fun SocialEntryScreen(
+ navController: NavHostController,
+ socialToken: String,
+ status: String,
+ onLoginSuccess: () -> Unit
+) {
+ LaunchedEffect(Unit) {
+
+ // social_auth_graph 스코프에 socialToken 저장
+ // 이후 모든 소셜 플로우 화면에서 안전하게 사용 가능
+ try{
+ navController
+ .getBackStackEntry("social_auth_graph")
+ .savedStateHandle["socialToken"] = socialToken
+ } catch (e: IllegalAccessException){
+ // social_auth_graph가 백스택에 없는 경우 처리
+ navController.popBackStack()
+ return@LaunchedEffect
+ }
+
+ when (status) {
+
+ // 이미 프로필이 완성된 유저
+ "ACTIVE" -> {
+ // 바로 홈으로 이동
+ onLoginSuccess()
+ }
+
+ // 추가 정보 입력이 필요한 유저
+ "TEMP" -> {
+ // 소셜 회원가입 플로우 시작
+ navController.navigate("social_login_gate") {
+ // 이 entry는 다시 돌아올 일 없으므로 제거
+ popUpTo("social_entry") {
+ inclusive = true
+ }
+ }
+ }
+
+ // 알 수 없는 상태
+ else -> {
+ // 로그인 화면으로 되돌리거나
+ navController.popBackStack()
+ }
+ }
+ }
+}
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/SocialGenderScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialGenderScreen.kt
new file mode 100644
index 00000000..4e7dfa80
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialGenderScreen.kt
@@ -0,0 +1,107 @@
+package com.example.login.ui.screen.social
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+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.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.core.model.auth.Gender
+import com.example.design.theme.LocalColorTheme
+import com.example.design.theme.font.Paperlogy
+import com.example.design.util.scaler
+import com.example.login.ui.item.BottomGradientButton
+import com.example.login.ui.item.OptionButton
+import com.example.login.ui.item.StepIndicator
+import com.example.login.viewmodel.SocialAuthViewModel
+
+@Composable
+fun SocialGenderScreen(
+ navigator: NavHostController,
+ viewModel: SocialAuthViewModel
+) {
+ // 디자인 테마
+ val colorTheme = LocalColorTheme.current
+
+ // SocialAuthViewModel 상태
+ val selectedGender by viewModel.gender.collectAsStateWithLifecycle()
+ val isButtonEnabled = selectedGender != Gender.NONE
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colorTheme.white)
+ ) {
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ start = 20.scaler,
+ end = 20.scaler,
+ top = 60.scaler,
+ bottom = 72.scaler
+ ),
+ horizontalAlignment = Alignment.Start
+ ) {
+
+ StepIndicator(
+ currentStep = 2,
+ totalSteps = 6,
+ label = "프로필 설정"
+ )
+
+ Spacer(Modifier.height(32.scaler))
+
+ Text(
+ text = "성별을\n선택해주세요",
+ fontSize = 22.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight.Bold,
+ color = colorTheme.black,
+ textAlign = TextAlign.Start
+ )
+
+ Spacer(Modifier.height(36.scaler))
+
+ OptionButton(
+ text = "남성",
+ selected = selectedGender == Gender.MALE,
+ onClick = {
+ viewModel.updateGender(Gender.MALE)
+ }
+ )
+
+ Spacer(Modifier.height(10.scaler))
+
+ OptionButton(
+ text = "여성",
+ selected = selectedGender == Gender.FEMALE,
+ onClick = {
+ viewModel.updateGender(Gender.FEMALE)
+ }
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+
+ BottomGradientButton(
+ text = "다음",
+ enabled = isButtonEnabled,
+ activeGradient = colorTheme.maincolor,
+ inactiveGradient = colorTheme.inactiveColor,
+ onClick = {
+ navigator.navigate("social_job") {
+ launchSingleTop = true
+ }
+ },
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/SocialInterestScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialInterestScreen.kt
new file mode 100644
index 00000000..ce27b8b6
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialInterestScreen.kt
@@ -0,0 +1,219 @@
+package com.example.login.ui.screen.social
+
+import CircleItem
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import androidx.navigation.NavHostController
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.core.model.auth.Interest
+import com.example.design.theme.LocalColorTheme
+import com.example.design.theme.font.Paperlogy
+import com.example.login.ui.item.BottomGradientButton
+import com.example.login.ui.item.StepIndicator
+import com.example.login.viewmodel.SocialAuthViewModel
+
+/* =========================
+ * UI 데이터 그대로 사용
+ * ========================= */
+
+data class InterestUI(
+ val emoji: String,
+ val interest: Interest,
+ val size: Float,
+ val offset: DpOffset
+)
+
+val interestUIList = listOf(
+ InterestUI("📈", Interest.BUSINESS, 159.29f, DpOffset(-208.dp, 261.dp)),
+ InterestUI("🎨", Interest.DESIGN, 181.72f, DpOffset(-32.dp, 243.dp)),
+ InterestUI("📚", Interest.STUDY, 145.82f, DpOffset(-89.dp, 420.dp)),
+ InterestUI("✍️", Interest.WRITING, 188.45f, DpOffset(72.dp, 410.dp)),
+ InterestUI("💻", Interest.IT, 107.69f, DpOffset(165.dp, 297.dp)),
+ InterestUI("🌍", Interest.SOCIETY, 187f, DpOffset(392.dp, 250.dp)),
+ InterestUI("🚀", Interest.STARTUP, 141.34f, DpOffset(260.dp, 365.dp)),
+ InterestUI("📂", Interest.COLLECT, 187f, DpOffset(260.dp, 519.dp)),
+ InterestUI("📰", Interest.CURRENT_EVENTS, 118f, DpOffset(136.dp, 613.dp)),
+ InterestUI("🧠", Interest.PSYCHOLOGY, 161.53f, DpOffset(-39.dp, 574.dp)),
+ InterestUI("🎯", Interest.CAREER, 125f, DpOffset(-179.dp, 553.dp)),
+ InterestUI("📓", Interest.INSIGHTS, 159.29f, DpOffset(442.dp, 448.dp))
+)
+
+/* =========================
+ * SocialInterestScreen
+ * ========================= */
+
+@Composable
+fun SocialInterestScreen(
+ navigator: NavHostController,
+ viewModel: SocialAuthViewModel,
+ onComplete: () -> Unit
+) {
+ val paperlogyFamily = Paperlogy.font
+ val colorTheme = LocalColorTheme.current
+
+ // 🔹 Social VM 상태
+ val savedInterests by viewModel.interests.collectAsStateWithLifecycle()
+
+ val selectedInterests = remember {
+ mutableStateListOf().apply {
+ addAll(savedInterests)
+ }
+ }
+
+ val canProceed = selectedInterests.isNotEmpty()
+
+ Scaffold(
+ containerColor = Color.White,
+ bottomBar = {
+ BottomGradientButton(
+ text = "완료",
+ enabled = canProceed,
+ activeGradient = colorTheme.maincolor,
+ inactiveGradient = colorTheme.inactiveColor,
+ onClick = {
+ if (selectedInterests.isEmpty()) return@BottomGradientButton
+
+ viewModel.updateInterests(selectedInterests.toList())
+ onComplete() // 네비게이션은 바깥에서
+ }
+ )
+ }
+ ) { innerPadding ->
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = 52.dp)
+ .padding(innerPadding)
+ .background(Color.White)
+ ) {
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ ) {
+ StepIndicator(
+ currentStep = 5,
+ totalSteps = 6,
+ label = "관심사 설정"
+ )
+
+ Spacer(Modifier.height(36.dp))
+
+ Text(
+ buildAnnotatedString {
+ append("어떤 분야의 콘텐츠를\n관심 있으신가요? ")
+ withStyle(
+ SpanStyle(
+ color = Color(0xFFE5ACF4),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium
+ )
+ ) {
+ append("(복수 선택 가능)")
+ }
+ },
+ fontSize = 22.sp,
+ fontFamily = paperlogyFamily,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(Modifier.height(50.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ InterestCloudScrollable(
+ interestUIList = interestUIList,
+ selectedInterests = selectedInterests,
+ onToggle = { interest ->
+ if (selectedInterests.contains(interest)) {
+ selectedInterests.remove(interest)
+ } else {
+ selectedInterests.add(interest)
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InterestCloudScrollable(
+ interestUIList: List,
+ selectedInterests: SnapshotStateList,
+ onToggle: (Interest) -> Unit,
+ height: Dp = 500.dp,
+ leftGutter: Dp = 20.dp,
+ rightGutter: Dp = 20.dp
+) {
+ val minY = interestUIList.minOfOrNull { it.offset.y } ?: 0.dp
+ val shifted = interestUIList.map {
+ it.copy(offset = DpOffset(it.offset.x, it.offset.y - minY))
+ }
+
+ val minX = shifted.minOfOrNull { it.offset.x } ?: 0.dp
+ val shiftX = -minX
+ val contentRight = shifted.maxOfOrNull { it.offset.x + it.size.dp } ?: 0.dp
+ val canvasWidth = leftGutter + contentRight + shiftX + rightGutter
+
+ val density = LocalDensity.current
+ val initialOffsetPx = remember {
+ with(density) { (90.dp + leftGutter).roundToPx() }
+ }
+
+ val scroll = rememberScrollState(initial = initialOffsetPx)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(height)
+ .horizontalScroll(scroll)
+ .background(Color.White),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .width(canvasWidth)
+ .height(height)
+ ) {
+ shifted.forEach { item ->
+ val isSelected = selectedInterests.contains(item.interest)
+ CircleItem(
+ emoji = item.emoji,
+ text = item.interest.displayName,
+ sizeDp = item.size,
+ selected = isSelected,
+ onClick = { onToggle(item.interest) },
+ modifier = Modifier.offset(
+ leftGutter + item.offset.x + shiftX,
+ item.offset.y
+ )
+ )
+ }
+ }
+ }
+}
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/SocialJobScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialJobScreen.kt
new file mode 100644
index 00000000..3b48b0a3
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialJobScreen.kt
@@ -0,0 +1,106 @@
+package com.example.login.ui.screen.social
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+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.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.core.model.auth.Job
+import com.example.design.theme.LocalColorTheme
+import com.example.design.theme.font.Paperlogy
+import com.example.design.util.scaler
+import com.example.login.ui.item.BottomGradientButton
+import com.example.login.ui.item.OptionButton
+import com.example.login.ui.item.StepIndicator
+import com.example.login.viewmodel.SocialAuthViewModel
+
+@Composable
+fun SocialJobScreen(
+ navigator: NavHostController,
+ viewModel: SocialAuthViewModel
+) {
+ // 디자인 테마
+ val colorTheme = LocalColorTheme.current
+
+ // SocialAuthViewModel 상태
+ val selectedJob by viewModel.job.collectAsStateWithLifecycle()
+ val jobs = Job.getAllJobs()
+
+ val isButtonEnabled = selectedJob != Job.NONE
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colorTheme.white)
+ ) {
+
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ start = 20.scaler,
+ end = 20.scaler,
+ top = 60.scaler,
+ bottom = 72.scaler
+ ),
+ horizontalAlignment = Alignment.Start
+ ) {
+
+ StepIndicator(
+ currentStep = 3,
+ totalSteps = 6,
+ label = "프로필 설정"
+ )
+
+ Spacer(Modifier.height(36.scaler))
+
+ Text(
+ text = "현재 하고 계신 일이나\n활동을 알려주세요",
+ fontSize = 22.sp,
+ lineHeight = 30.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight.Bold,
+ color = colorTheme.black,
+ textAlign = TextAlign.Start
+ )
+
+ Spacer(Modifier.height(32.scaler))
+
+ jobs.forEach { job ->
+ OptionButton(
+ text = job.displayName,
+ selected = selectedJob == job,
+ onClick = {
+ viewModel.updateJob(job)
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(Modifier.height(12.scaler))
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+
+
+ BottomGradientButton(
+ text = "다음",
+ enabled = isButtonEnabled,
+ activeGradient = colorTheme.maincolor,
+ inactiveGradient = colorTheme.inactiveColor,
+ onClick = {
+ navigator.navigate("social_purpose") {
+ launchSingleTop = true
+ }
+ },
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+}
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/SocialNicknameScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialNicknameScreen.kt
new file mode 100644
index 00000000..6fe4cd4f
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialNicknameScreen.kt
@@ -0,0 +1,128 @@
+package com.example.login.ui.screen.social
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import com.example.core.model.auth.NicknameCheckState
+import com.example.design.theme.LocalColorTheme
+import com.example.design.theme.font.Paperlogy
+import com.example.design.util.scaler
+import com.example.login.ui.item.BottomGradientButton
+import com.example.login.ui.item.LoginTextField
+import com.example.login.ui.item.PasswordRuleItem
+import com.example.login.ui.item.StepIndicator
+import com.example.login.ui.item.WrongRuleItem
+import com.example.login.viewmodel.SocialAuthViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+
+@Composable
+fun SocialNicknameScreen(
+ navigator: NavHostController,
+ viewModel: SocialAuthViewModel
+) {
+ // 디자인 테마
+ val colorTheme = LocalColorTheme.current
+
+ // 🔹 SocialAuthViewModel 상태 수집
+ val nickname by viewModel.nickname.collectAsStateWithLifecycle()
+ val nicknameState by viewModel.nicknameCheckState.collectAsStateWithLifecycle()
+
+
+ // 닉네임 유효성 (기존 로직 그대로)
+ val isNicknameValid =
+ nickname.isNotBlank() && nickname.length <= 6
+
+ val isButtonEnabled =
+ isNicknameValid && nicknameState == NicknameCheckState.Available
+
+ Box(modifier = Modifier.fillMaxSize()) {
+
+ /* =======================
+ * 본문 영역
+ * ======================= */
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ start = 20.scaler,
+ end = 20.scaler,
+ top = 60.scaler,
+ bottom = 72.scaler
+ ),
+ horizontalAlignment = Alignment.Start
+ ) {
+
+ StepIndicator(
+ currentStep = 1,
+ totalSteps = 4,
+ label = "프로필 설정"
+ )
+
+ Spacer(Modifier.height(32.scaler))
+
+ Text(
+ text = "사용하실 닉네임을\n입력해주세요",
+ fontSize = 22.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight.Bold,
+ color = colorTheme.black
+ )
+
+ Spacer(Modifier.height(40.scaler))
+
+ LoginTextField(
+ value = nickname,
+ onValueChange = { input ->
+ // 소셜 뷰모델에 닉네임 전달
+ viewModel.updateNickname(input)
+
+ },
+ hint = "닉네임을 입력해주세요.",
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(Modifier.height(10.scaler))
+
+ when (nicknameState) {
+ is NicknameCheckState.Duplicated -> {
+ WrongRuleItem(
+ text = "이미 사용 중인 닉네임입니다.",
+ modifier = Modifier.padding(start = 12.scaler)
+ )
+ }
+
+ else -> {
+ PasswordRuleItem(
+ text = "국문/영문 6자 이하",
+ satisfied = isNicknameValid,
+ modifier = Modifier.padding(start = 12.scaler)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+
+ /* =======================
+ * 하단 버튼
+ * ======================= */
+ BottomGradientButton(
+ text = "다음",
+ enabled = isButtonEnabled,
+ activeGradient = colorTheme.maincolor,
+ inactiveGradient = colorTheme.inactiveColor,
+ onClick = {
+ navigator.navigate("social_gender") {
+ launchSingleTop = true
+ }
+ },
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+}
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/SocialPurposeScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialPurposeScreen.kt
new file mode 100644
index 00000000..6634a658
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/SocialPurposeScreen.kt
@@ -0,0 +1,220 @@
+package com.example.login.ui.screen.social
+
+import CircleItem
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import androidx.navigation.NavHostController
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.core.model.auth.Purpose
+import com.example.design.theme.LocalColorTheme
+import com.example.design.theme.font.Paperlogy
+import com.example.login.ui.item.StepIndicator
+import com.example.login.ui.item.BottomGradientButton
+import com.example.login.viewmodel.SocialAuthViewModel
+import com.example.design.util.scaler
+
+/* =========================
+ * UI 데이터 / 레이아웃 그대로 사용
+ * ========================= */
+
+data class PurposeUI(
+ val emoji: String,
+ val purpose: Purpose,
+ val size: Float,
+ val offset: DpOffset
+)
+
+val purposeUIList = listOf(
+ PurposeUI("🎓", Purpose.CAREER, 159.29f, DpOffset(-102.dp, 293.29.dp)),
+ PurposeUI("📅", Purpose.LATER_READING, 219.86f, DpOffset(-66.79.dp, 499.49.dp)),
+ PurposeUI("💡", Purpose.SIDE_PROJECT, 181.72f, DpOffset(59.68.dp, 335.6.dp)),
+ PurposeUI("❓", Purpose.OTHERS, 107.69f, DpOffset(167.56.dp, 514.58.dp)),
+ PurposeUI("🧠", Purpose.SELF_DEVELOPMENT, 145.82f, DpOffset(220.88.dp, 243.dp)),
+ PurposeUI("📝", Purpose.STUDY, 141.34f, DpOffset(256.08.dp, 401.92.dp)),
+ PurposeUI("💼", Purpose.WORK, 186.21f, DpOffset(274.18.dp, 551.79.dp)),
+ PurposeUI("💻", Purpose.CREATION_REFERENCE, 188.45f, DpOffset(374.77.dp, 272.17.dp)),
+ PurposeUI("🧠", Purpose.INSIGHTS, 161.53f, DpOffset(444.17.dp, 465.29.dp)),
+)
+
+/* =========================
+ * SocialPurposeScreen
+ * ========================= */
+
+@Composable
+fun SocialPurposeScreen(
+ navigator: NavHostController,
+ viewModel: SocialAuthViewModel
+) {
+ val colorTheme = LocalColorTheme.current
+ val paperlogyFamily = Paperlogy.font
+
+ // 🔹 Social VM 상태 수집
+ val savedPurposes by viewModel.purposes.collectAsStateWithLifecycle()
+
+ // 🔹 UI 선택 상태 (기존 구조 그대로)
+ val selectedPurposes = remember {
+ mutableStateListOf().apply {
+ addAll(savedPurposes)
+ }
+ }
+
+ val canProceed = selectedPurposes.isNotEmpty()
+
+ Scaffold(
+ containerColor = Color.White,
+ bottomBar = {
+ BottomGradientButton(
+ text = "다음",
+ enabled = canProceed,
+ activeGradient = colorTheme.maincolor,
+ inactiveGradient = colorTheme.inactiveColor,
+ onClick = {
+ if (selectedPurposes.isEmpty()) return@BottomGradientButton
+
+ viewModel.updatePurposes(selectedPurposes.toList())
+ navigator.navigate("social_interest")
+ }
+ )
+ }
+ ) { innerPadding ->
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = 52.dp)
+ .padding(innerPadding)
+ .background(Color.White)
+ ) {
+
+ Box(modifier = Modifier.padding(horizontal = 20.dp)) {
+ StepIndicator(
+ currentStep = 4,
+ totalSteps = 6,
+ label = "관심사 설정"
+ )
+ }
+
+ Spacer(Modifier.height(36.dp))
+
+ Text(
+ buildAnnotatedString {
+ append("어떤 목적으로 링크를\n저장하고 싶으신가요? ")
+ withStyle(
+ SpanStyle(
+ color = Color(0xFFE5ACF4),
+ fontSize = 16.sp,
+ fontFamily = paperlogyFamily,
+ fontWeight = FontWeight.Medium
+ )
+ ) {
+ append("(복수 선택 가능)")
+ }
+ },
+ fontSize = 22.sp,
+ lineHeight = 30.sp,
+ fontFamily = paperlogyFamily,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 20.dp)
+ )
+
+ Spacer(modifier = Modifier.height(50.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ PurposeCloudScrollable(
+ purposeUIList = purposeUIList,
+ selectedPurposes = selectedPurposes,
+ onToggle = { purpose ->
+ if (selectedPurposes.contains(purpose)) {
+ selectedPurposes.remove(purpose)
+ } else {
+ selectedPurposes.add(purpose)
+ }
+ },
+ height = 495.dp
+ )
+ }
+ }
+ }
+}
+@Composable
+private fun PurposeCloudScrollable(
+ purposeUIList: List,
+ selectedPurposes: SnapshotStateList,
+ onToggle: (Purpose) -> Unit,
+ height: Dp = 320.dp,
+ leftGutter: Dp = 20.dp,
+ rightGutter: Dp = 20.dp
+) {
+ // 4a14c5 y좌표 전체를 minY로 보정해서 top에 맞게 이동시키기!
+ val minY = purposeUIList.minOfOrNull { it.offset.y } ?: 0.dp
+ val shiftY = 0.dp // 필요하면 위쪽 여백(10~20dp) 추가
+ val shiftedPurposes = purposeUIList.map { p ->
+ p.copy(offset = DpOffset(p.offset.x, (p.offset.y - minY) + shiftY))
+ }
+
+ val minX = shiftedPurposes.minOfOrNull { it.offset.x } ?: 0.dp
+ val shiftX = -minX
+ val contentRight = shiftedPurposes.maxOfOrNull { it.offset.x + it.size.dp } ?: 0.dp
+ val canvasWidth = leftGutter + contentRight + shiftX + rightGutter
+
+ // X� initial scroll as before
+ val density = LocalDensity.current
+ val initialOffsetDp = 90.dp + leftGutter
+ val initialOffsetPx = remember { with(density) { initialOffsetDp.roundToPx() } }
+ val scroll = rememberScrollState(initial = initialOffsetPx)
+ LaunchedEffect(canvasWidth) {
+ if (scroll.value == 0) scroll.scrollTo(initialOffsetPx)
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(height)
+ .horizontalScroll(scroll)
+ .background(Color.White),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .width(canvasWidth)
+ .height(height)
+ ) {
+ shiftedPurposes.forEach { p ->
+ val isSelected = selectedPurposes.contains(p.purpose)
+ CircleItem(
+ emoji = p.emoji,
+ text = p.purpose.displayName,
+ sizeDp = p.size,
+ selected = isSelected,
+ onClick = { onToggle(p.purpose) },
+ modifier = Modifier.offset(
+ leftGutter + p.offset.x + shiftX,
+ p.offset.y
+ )
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/login/src/main/java/com/example/login/ui/screen/social/WelcomeSocialScreen.kt b/feature/login/src/main/java/com/example/login/ui/screen/social/WelcomeSocialScreen.kt
new file mode 100644
index 00000000..0c5e1c2c
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/ui/screen/social/WelcomeSocialScreen.kt
@@ -0,0 +1,251 @@
+package com.example.login.ui.screen.social
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.rememberNavController
+import com.example.login.R
+import com.example.design.theme.font.Paperlogy
+import androidx.compose.runtime.collectAsState
+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.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import com.example.design.theme.LocalColorTheme
+import com.example.design.util.scaler
+import com.example.login.viewmodel.SignUpViewModel
+import android.app.Activity
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+import com.example.core.model.SystemBarMode
+import com.example.core.model.auth.SignUpState
+import com.example.core.system.SystemBarController
+
+@Composable
+fun WelcomeSocialScreen(
+ navigator: NavHostController,
+ signUpViewModel: SignUpViewModel? = null,
+ onLoginSuccess: () -> Unit = {}
+) {
+ //디자인 모듈 가져오기.
+ val colorTheme = LocalColorTheme.current
+ val density = LocalDensity.current
+ val configuration = LocalConfiguration.current
+
+ // 시스템 바 숨기기 설정
+ val systemBarController = LocalContext.current as? SystemBarController
+ val isPreview = LocalInspectionMode.current
+
+ DisposableEffect(Unit) {
+ if (!isPreview && systemBarController != null) {
+ systemBarController.setSystemBarMode(SystemBarMode.HIDDEN)
+ }
+ onDispose {
+ // WelcomeScreen을 떠날 때 다시 바텀바를 보여줌
+ if (!isPreview && systemBarController != null) {
+ systemBarController.setSystemBarMode(SystemBarMode.VISIBLE)
+ }
+ }
+ }
+
+ // 뒤로가기 막기
+ BackHandler {
+ // 아무것도 하지 않음 → 뒤로가기 무시됨 -> 아예 이전 화원가입 했던 화면들 돌아갈 수 없음!
+ }
+ // signUpState 사용
+ val signUpState by signUpViewModel?.signUpState?.collectAsState() ?: remember {
+ mutableStateOf(SignUpState.Idle)
+ }
+ //val signUpSuccess by signUpViewModel.signUpSuccess.collectAsState()
+ var isSignUpRequested by remember { mutableStateOf(false) } //중복 호출 방자용 상태 추가
+
+ //화면 진입 시 자동 회원가입 요청
+ LaunchedEffect(Unit) {// Unit은 절대 안 바뀜. 그렇지만 이게 맞는 설계이니
+ if (!isSignUpRequested) {
+ isSignUpRequested = true
+ Log.d("WelcomeScreen", "Welcome 진입 → 회원가입 자동 요청")
+ signUpViewModel?.signUp()
+ }
+ }
+
+ // 서버 응답 감지
+ LaunchedEffect(signUpState) {
+ when (signUpState) {
+ is SignUpState.Success -> {
+ Log.d("WelcomeScreen", "회원가입 성공")
+ onLoginSuccess() // MainApp의 홈 이동 로직 호출
+ navigator.navigate("email_login") {
+ popUpTo("auth_graph") { inclusive = true }
+ }
+ isSignUpRequested = false
+ }
+ // 재시도 문제가 있음. 사용자에게 회원가입 시패시 안내를 하고 재시도를 하도록 해야함.
+ is SignUpState.Error -> {
+ val message = (signUpState as SignUpState.Error).message
+ Log.e("WelcomeScreen", "회원가입 실패: $message")
+ isSignUpRequested = false
+ }
+ is SignUpState.Loading -> {
+ Log.d("WelcomeScreen", "회원가입 진행 중...")
+ }
+ is SignUpState.Idle -> {
+ // 초기 상태
+ }
+ }
+ }
+
+ // 하단 동적 패딩 계산 로직 -> 기존 회원가입 바텀 그라데이션 버튼과 동일하게 작동함.
+ val imeBottom = WindowInsets.ime.getBottom(density)
+ val navBottom = WindowInsets.navigationBars.getBottom(density)
+ val screenHeight = configuration.screenHeightDp.dp
+
+ val bottomPadding = when {
+ imeBottom > 0 -> 20.dp
+ navBottom > 0 -> screenHeight * (16f / 917f)
+ else -> screenHeight * (24f / 917f)
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ Color(0xFF2C6FFF), // 위
+ Color(0xFFC800FF) // 아래
+ )
+ )
+ )
+ ) {
+ // 중앙 콘텐츠 (Column)
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ // 1. 중앙 콘텐츠 레이어 (로고 및 텍스트)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter) // 상단 기준 정렬 후 offset으로 세밀하게 이동
+ .offset(y = (configuration.screenHeightDp.dp * (394f / 917f)) - (65.scaler / 2)),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_logo_white),
+ contentDescription = "Logo",
+ modifier = Modifier
+ // X축 오프셋은 기존 계산식 유지
+ .offset(x = (160.scaler) - (configuration.screenWidthDp.dp / 2) + (46.scaler))
+ .width(92.scaler)
+ .height(65.scaler),
+ contentScale = ContentScale.Fit
+ )
+
+ Spacer(modifier = Modifier.height(20.scaler))
+
+ Text(
+ text = "링큐에 오신 걸 환영해요!",
+ color = colorTheme.white,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = Paperlogy.font,
+ textAlign = TextAlign.Center // 피그마와 동일하게 중앙 정렬
+ )
+
+ Spacer(modifier = Modifier.height(16.scaler))
+
+ Text(
+ text = "당신을 위한 링크, 링큐가 기억하고 연결해줄게요!",
+ color = colorTheme.white,
+ fontSize = 16.sp,
+ fontFamily = Paperlogy.font,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ // 2. 하단 버튼 레이어
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .padding(start = 20.scaler, end = 20.scaler, bottom = bottomPadding)
+ .height(50.scaler)
+ .background(Color.White, shape = RoundedCornerShape(18.dp))
+ .clickable {
+ when (signUpState) {
+ is SignUpState.Error -> {
+ // 실패 시 재시도
+ isSignUpRequested = false
+ signUpViewModel?.signUp()
+ }
+
+ is SignUpState.Success -> onLoginSuccess()
+ else -> {} // Loading 중엔 무시
+ }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ // 상태에 따라 버튼 내용 변경
+ when (signUpState) {
+ is SignUpState.Loading -> CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = Color(0xFFC800FF),
+ strokeWidth = 2.dp
+ )
+
+ is SignUpState.Error -> Text(
+ text = "다시 시도하기",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ style = TextStyle(brush = colorTheme.maincolor),
+ fontFamily = Paperlogy.font
+ )
+
+ else -> Text(
+ text = "홈으로 이동하기",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ style = TextStyle(brush = colorTheme.maincolor),
+ fontFamily = Paperlogy.font
+ )
+ }
+ }
+
+ }
+
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun WelcomeScreenPreview() {
+ val fakeNavController = rememberNavController()
+ WelcomeSocialScreen(navigator = fakeNavController)
+}
+
diff --git a/feature/login/src/main/java/com/example/login/viewmodel/EmailAuthViewModel.kt b/feature/login/src/main/java/com/example/login/viewmodel/EmailAuthViewModel.kt
index f3f0dd23..aabec64b 100644
--- a/feature/login/src/main/java/com/example/login/viewmodel/EmailAuthViewModel.kt
+++ b/feature/login/src/main/java/com/example/login/viewmodel/EmailAuthViewModel.kt
@@ -4,6 +4,8 @@ import android.util.Log
import android.util.Patterns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.example.core.model.auth.AuthErrorMessages
+import com.example.core.model.auth.EmailAuthState
import com.example.core.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
@@ -17,26 +19,6 @@ import javax.inject.Inject
import kotlin.random.Random
import kotlinx.coroutines.Job
-sealed class EmailAuthState {
- object Idle : EmailAuthState()
- object Sending : EmailAuthState()
- data class SendSuccess(val message: String) : EmailAuthState()
- data class SendError(val message: String) : EmailAuthState()
- object Verifying : EmailAuthState()
- object VerifySuccess : EmailAuthState()
- data class VerifyError(val message: String) : EmailAuthState()
-}
-
-// 에러 메시지 상수
-object AuthErrorMessages {
- const val INVALID_EMAIL_FORMAT = "잘못된 이메일 형식"
- const val EMAIL_ALREADY_EXISTS = "이미 가입된 이메일입니다."
- const val SERVER_ERROR = "서버 오류"
- const val VERIFY_FAILED = "인증 실패"
- const val NETWORK_ERROR = "네트워크 오류"
- const val INVALID_CODE = "이메일 인증 코드가 잘못 입력 되었습니다."
-}
-
//여기 api 전면 수정 예정. 실제 api 연동은 1월 말~ 2월 초
// TODO : 하진 언니에게 otp 번호 생성은 백에서 할 수 있도록 수정 요청하기!
@HiltViewModel
diff --git a/feature/login/src/main/java/com/example/login/viewmodel/LoginViewModel.kt b/feature/login/src/main/java/com/example/login/viewmodel/LoginViewModel.kt
index 6b9fb2a7..19057c15 100644
--- a/feature/login/src/main/java/com/example/login/viewmodel/LoginViewModel.kt
+++ b/feature/login/src/main/java/com/example/login/viewmodel/LoginViewModel.kt
@@ -15,6 +15,11 @@ import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
+import com.example.core.model.auth.AutoLoginState
+import com.example.core.model.auth.LoginErrorType
+import com.example.core.model.auth.LoginState
+import com.example.core.model.auth.SocialLoginData
+import com.example.core.model.auth.SocialLoginEvent
/**
* 세션 정리
@@ -30,27 +35,6 @@ import javax.inject.Inject
* */
-// 로그인 상태 리펙토링
-sealed class LoginState {
- object Idle : LoginState() // 초기 상태
- object Loading : LoginState() // 로그인 진행 중
- data class Success(val result: LoginResult) : LoginState() // 성공
- data class Error(val errorType: LoginErrorType) : LoginState() // 실패
-}
-
-enum class LoginErrorType(val message: String) {
- INVALID_CREDENTIALS("이메일 또는 비밀번호가 올바르지 않습니다."),
- SERVER_ERROR("서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
- NETWORK_ERROR("네트워크 연결을 확인해주세요."),
- UNKNOWN_ERROR("알 수 없는 오류가 발생했습니다.")
-}
-
-sealed class AutoLoginState {
- object Idle : AutoLoginState()
- object Checking : AutoLoginState()
- object Success : AutoLoginState()
- object Failed : AutoLoginState()
-}
@HiltViewModel
@@ -223,4 +207,86 @@ open class LoginViewModel @Inject constructor(
companion object {
private const val TAG = "LoginViewModel"
}
+ // 소셜 로그인 토큰 처리 (딥링크를 통해 받은 토큰 처리)
+// TODO: 백엔드 수정 완료 후 아래 내용 업데이트 필요
+// 1. refreshToken 딥링크 응답에 추가되면 → authPreference.saveTokens에 실제값 저장
+// 2. GET /api/users/me API 추가되면 → userId 조회 후 fetchAndSaveUserSession 호출
+// 3. 현재는 자동 로그인 불가 상태 (refreshToken 빈값으로 isLoggedIn = false)
+ // 소셜 로그인 토큰 처리(딥링크를 통해 받은 토큰 처리)
+ private val _socialLoginEvent = MutableStateFlow(null)
+ val socialLoginEvent: StateFlow = _socialLoginEvent
+
+ fun consumeSocialLoginEvent() {
+ _socialLoginEvent.value = null
+ }
+
+ fun handleSocialDeepLink(data: SocialLoginData) {
+ viewModelScope.launch {
+ Log.d("SOCIAL_VM", "handleSocialDeepLink 호출됨: $data")
+ try {
+ _loginState.value = LoginState.Loading
+
+ when {
+ // 기존 유저 - 바로 홈으로
+ data.result == "SUCCESS" && data.status == "ACTIVE" -> {
+ Log.d("SOCIAL_VM", "ACTIVE 케이스 진입")
+ val accessToken = data.accessToken ?: run {
+ _loginState.value = LoginState.Error(LoginErrorType.UNKNOWN_ERROR)
+ return@launch
+ }
+ val refreshToken = data.refreshToken ?: run {
+ _loginState.value = LoginState.Error(LoginErrorType.UNKNOWN_ERROR)
+ return@launch
+ }
+ // TODO: 서원이 /api/users/me API 확인 후 아래 작업 필요
+ // 1. GET /api/users/me 호출 → 실제 userId 조회
+ // 2. authPreference.saveTokens(userId = 실제값) 으로 교체
+ // 3. fetchAndSaveUserSession(userId) 호출 → 세션 풀 세팅
+ // 4. 현재는 userId=0L 임시값이라 자동 로그인 불가 상태
+
+ // TODO: 서원이 /api/users/me 확인 후 userId 실제값으로 교체
+ authPreference.saveTokens(
+ accessToken = accessToken,
+ refreshToken = refreshToken,
+ userId = 0L // TODO: 실제 userId로 교체 필요 - 지금 자동 로그인 불가, 닉네임 제대로 안 내려옴.
+ )
+ Log.d(TAG, "소셜 ACTIVE 토큰 저장 완료")
+
+ _loginState.value = LoginState.Success(
+ LoginResult(
+ accessToken = accessToken,
+ refreshToken = refreshToken,
+ userId = 0,
+ status = "ACTIVE",
+ inactiveDate = null
+ )
+ )
+ }
+
+ // 신규 유저 - 프로필 입력 화면으로
+ data.result == "SUCCESS" && data.status == "TEMP" -> {
+ val socialToken = data.socialToken ?: run {
+ _loginState.value = LoginState.Error(LoginErrorType.UNKNOWN_ERROR)
+ return@launch
+ }
+ Log.d(TAG, "소셜 TEMP → SocialEntry로 이동")
+
+ _socialLoginEvent.value = SocialLoginEvent.NavigateToSocialEntry(
+ socialToken = socialToken,
+ provider = data.provider
+ )
+ _loginState.value = LoginState.Idle
+ }
+
+ data.result == "FAIL" -> {
+ Log.e(TAG, "소셜 로그인 실패: ${data.errorCode}")
+ _loginState.value = LoginState.Error(LoginErrorType.UNKNOWN_ERROR)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "소셜 딥링크 처리 실패", e)
+ _loginState.value = LoginState.Error(LoginErrorType.UNKNOWN_ERROR)
+ }
+ }
+ }
}
diff --git a/feature/login/src/main/java/com/example/login/viewmodel/SignUpViewModel.kt b/feature/login/src/main/java/com/example/login/viewmodel/SignUpViewModel.kt
index 28dee53c..5917d0ce 100644
--- a/feature/login/src/main/java/com/example/login/viewmodel/SignUpViewModel.kt
+++ b/feature/login/src/main/java/com/example/login/viewmodel/SignUpViewModel.kt
@@ -6,6 +6,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.example.core.model.auth.Gender
+import com.example.core.model.auth.Interest
+import com.example.core.model.auth.NicknameCheckState
+import com.example.core.model.auth.Purpose
+import com.example.core.model.auth.SignUpForm
+import com.example.core.model.auth.SignUpState
import com.example.core.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
@@ -13,115 +19,6 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
-// 모든 회원가입 입력 데이터를 담는 데이터 클래스
-data class SignUpForm(
- val email: String = "",
- val password: String = "",
- val nickname: String = "",
- val gender: Gender = Gender.NONE,
- val jobId: Int = 0,
- val purposeList: List = emptyList(),
- val interestList: List = emptyList(),
- val agreeTerms: Boolean = false, // 필수
- val agreePrivacy: Boolean = false, // 필수
- val agreeMarketing: Boolean = false // 선택
-)
-
-// signUpSuccess는 null 허용 안 할 순 없어? -> 이렇게 아예 state로 분리하는 것은 어떤지.
-sealed class SignUpState {
- object Idle : SignUpState() // 초기 상태
- object Loading : SignUpState() // 진행 중
- object Success : SignUpState() // 성공
- data class Error(val message: String) : SignUpState() // 실패
-}
-
-// 닉네임 중복 상태 체크
-sealed class NicknameCheckState {
- object Idle : NicknameCheckState()
- object Checking : NicknameCheckState() // Loading 역할
- object Available : NicknameCheckState()
- object Duplicated : NicknameCheckState()
- data class Error(val message: String) : NicknameCheckState()
-}
-
-// 성별
-enum class Gender(val value: Int){
- NONE(0), // 미선택 판단
- MALE(1), // 남성인 경우 api에 gender 값으로 1이 전달됩니다.
- FEMALE(2) // 여성인 경우 api에 gender 값으로 2이 전달됩니다.
-}
-
-// 직업
-enum class Job(val id: Int, val displayName: String) {
- NONE(0, "미선택"),
- HIGH_SCHOOL(1, "고등학생"),
- COLLEGE(2, "대학생"),
- WORKER(3, "직장인"),
- SELF_EMPLOYED(4, "자영업자"),
- FREELANCER(5, "프리랜서"),
- JOB_SEEKER(6, "취준생");
-
- companion object {
- // NONE을 제외한 선택 가능한 직업 리스트
- fun getAllJobs(): List = values().filter { it != NONE }
-
- // ID로 Job 찾기
- fun fromId(id: Int): Job = values().find { it.id == id } ?: NONE
- }
-}
-
-// 목적
-enum class Purpose(val code: String, val displayName: String) {
- SELF_DEVELOPMENT("SELF_DEVELOPMENT", "자기개발\n/정보수집"),
- SIDE_PROJECT("SIDE_PROJECT", "사이드 프로젝트\n/창업준비"),
- OTHERS("OTHERS", "기타"),
- LATER_READING("LATER_READING", "그냥 나중에\n읽고 싶은 글 저장"),
- CAREER("CAREER", "취업 커리어 준비"),
- CREATION_REFERENCE("CREATION_REFERENCE", "블로그/콘텐츠 작성 참고용"),
- INSIGHTS("INSIGHTS", "인사이트 모으기"),
- WORK("WORK", "업무자료 아카이빙"),
- STUDY("STUDY", "학업/리포트 정리");
-
- companion object {
- // 모든 Purpose 리스트 반환
- fun getAllPurposes(): List = values().toList()
-
- // code로 Purpose 찾기
- fun fromCode(code: String): Purpose? = values().find { it.code == code }
-
- // displayName으로 Purpose 찾기
- fun fromDisplayName(displayName: String): Purpose? =
- values().find { it.displayName == displayName }
- }
-}
-
-// 관심사
-enum class Interest(val code: String, val displayName: String) {
- BUSINESS("BUSINESS", "비즈니스/마케팅"),
- DESIGN("DESIGN", "디자인/\n크리에이티브"),
- IT("IT", "IT/개발"),
- STARTUP("STARTUP", "스타트업/창업"),
- SOCIETY("SOCIETY", "사회/문화/환경"),
- STUDY("STUDY", "학업/\n리포트 참고"),
- WRITING("WRITING", "글쓰기/콘텐츠\n작성"),
- INSIGHTS("INSIGHTS", "책/인사이트\n요약"),
- PSYCHOLOGY("PSYCHOLOGY", "심리/자기계발"),
- CURRENT_EVENTS("CURRENT_EVENTS", "시사/트렌드"),
- COLLECT("COLLECT", "그냥 모아두고\n싶은 글들"),
- CAREER("CAREER", "커리어/채용");
-
- companion object {
- // 모든 Interest 리스트 반환
- fun getAllInterests(): List = values().toList()
-
- // code로 Interest 찾기
- fun fromCode(code: String): Interest? = values().find { it.code == code }
-
- // displayName으로 Interest 찾기
- fun fromDisplayName(displayName: String): Interest? =
- values().find { it.displayName == displayName }
- }
-}
@HiltViewModel
class SignUpViewModel @Inject constructor(
@@ -264,8 +161,8 @@ class SignUpViewModel @Inject constructor(
Log.d("SignUpViewModel", "[회원가입 요청] $signUpForm")
// Purpose enum의 code 값을 List으로 변환
- val purposeCodes = signUpForm.purposeList.map { it.code }
- val interestCodes = signUpForm.interestList.map { it.code }
+ val purposeKeys = signUpForm.purposeList.map { it.serverKey }
+ val interestKeys = signUpForm.interestList.map { it.serverKey }
val success = userRepository.signUp(
nickname = signUpForm.nickname,
@@ -273,8 +170,8 @@ class SignUpViewModel @Inject constructor(
password = signUpForm.password,
gender = signUpForm.gender.value,
jobId = signUpForm.jobId,
- purposeList = purposeCodes,
- interestList = interestCodes
+ purposeList = purposeKeys,
+ interestList = interestKeys
)
_signUpState.value = if (success) {
diff --git a/feature/login/src/main/java/com/example/login/viewmodel/SocialAuthViewModel.kt b/feature/login/src/main/java/com/example/login/viewmodel/SocialAuthViewModel.kt
new file mode 100644
index 00000000..eb318c6b
--- /dev/null
+++ b/feature/login/src/main/java/com/example/login/viewmodel/SocialAuthViewModel.kt
@@ -0,0 +1,193 @@
+package com.example.login.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.core.model.auth.Gender
+import com.example.core.model.auth.Job
+import com.example.core.model.auth.Purpose
+import com.example.core.model.auth.Interest
+import com.example.core.repository.UserRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import com.example.core.model.auth.NicknameCheckState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+
+
+/**
+ * SocialAuthViewModel
+ *
+ * 소셜 로그인 이후 TEMP 유저가
+ * 닉네임 / 성별 / 직업 / 목적 / 관심사를 입력하는 전용 ViewModel
+ *
+ * 이메일 / 비밀번호 없음
+ * SessionStore 직접 접근 안 함
+ * 마지막 단계에서 completeSocialProfile API 호출
+ */
+
+
+@HiltViewModel
+class SocialAuthViewModel @Inject constructor(
+ private val userRepository: UserRepository
+) : ViewModel() {
+
+ companion object {
+ private const val TAG = "SocialAuthViewModel"
+ private const val NICKNAME_DEBOUNCE_TIME = 500L
+ private const val MAX_NICKNAME_LENGTH = 6
+ }
+
+ // 입력 상태
+ private val _nickname = MutableStateFlow("")
+ val nickname: StateFlow = _nickname
+
+ private val _nicknameCheckState =
+ MutableStateFlow(NicknameCheckState.Idle)
+ val nicknameCheckState: StateFlow = _nicknameCheckState
+
+ private val nicknameQuery = MutableStateFlow("")
+
+ init {
+ viewModelScope.launch {
+ nicknameQuery
+ .debounce(NICKNAME_DEBOUNCE_TIME)
+ .distinctUntilChanged()
+ .filter { isValidNickname(it) }
+ .collect { query ->
+ checkNicknameInternal(query)
+ }
+ }
+ }
+
+ private val _gender = MutableStateFlow(Gender.NONE)
+ val gender: StateFlow = _gender
+
+ private val _job = MutableStateFlow(Job.NONE)
+ val job: StateFlow = _job
+
+ private val _purposes = MutableStateFlow>(emptyList())
+ val purposes: StateFlow> = _purposes
+
+ private val _interests = MutableStateFlow>(emptyList())
+ val interests: StateFlow> = _interests
+
+
+ // 로딩, 성공, 에러
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading: StateFlow = _isLoading
+
+ private val _error = MutableStateFlow(null)
+ val error: StateFlow = _error
+
+
+
+ private fun isValidNickname(input: String): Boolean =
+ input.isNotBlank() && input.length in 1..MAX_NICKNAME_LENGTH
+
+ private fun checkNicknameInternal(nickname: String) {
+ viewModelScope.launch {
+ try {
+ _nicknameCheckState.value = NicknameCheckState.Checking
+
+ // 실제 서버 API 호출
+ val available = userRepository.checkNickname(nickname)
+
+ _nicknameCheckState.value =
+ if (available) {
+ NicknameCheckState.Available
+ } else {
+ NicknameCheckState.Duplicated
+ }
+
+ } catch (e: Exception) {
+ Log.e(TAG, "닉네임 중복 체크 실패", e)
+ _nicknameCheckState.value =
+ NicknameCheckState.Error(
+ e.message ?: "닉네임 확인 중 오류가 발생했습니다."
+ )
+ }
+ }
+ }
+
+ fun updateNickname(input: String) {
+ if (_nickname.value == input) return
+ _nickname.value = input
+
+ if (isValidNickname(input)) {
+ nicknameQuery.value = input
+ } else {
+ _nicknameCheckState.value = NicknameCheckState.Idle
+ }
+ }
+
+ fun updateGender(value: Gender) {
+ _gender.value = value
+ }
+
+ fun updateJob(value: Job) {
+ _job.value = value
+ }
+
+ fun updatePurposes(values: List) {
+ _purposes.value = values
+ }
+
+ fun updateInterests(values: List) {
+ _interests.value = values
+ }
+
+ fun clearError() {
+ _error.value = null
+ }
+
+
+ //소셜 프로필 완료 API
+ fun completeSocialProfile(
+ socialToken: String,
+ onSuccess: () -> Unit
+ ) {
+ // 기본 검증 (UI에서도 하지만 여기서 한 번 더)
+ if (_nickname.value.isBlank()) {
+ Log.w(TAG, "닉네임이 비어 있음")
+ return
+ }
+
+ viewModelScope.launch {
+ try {
+ _isLoading.value = true
+ _error.value = null
+
+ Log.d(TAG, "소셜 프로필 완료 API 호출 시작")
+
+ val success = userRepository.completeSocialProfile(
+ socialToken = socialToken,
+ nickname = _nickname.value,
+ gender = _gender.value,
+ job = _job.value,
+ purposes = _purposes.value,
+ interests = _interests.value
+ )
+
+ if (success) {
+ Log.d(TAG, "소셜 프로필 완료 성공")
+ onSuccess()
+ } else {
+ Log.e(TAG, "소셜 프로필 완료 실패 (서버 반환 false)")
+ _error.value = IllegalStateException("프로필 저장에 실패했습니다.")
+ }
+
+ } catch (e: Exception) {
+ Log.e(TAG, "소셜 프로필 완료 실패: ${e.message}")
+ _error.value = e
+ } finally {
+ _isLoading.value = false
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt
index 18392e6e..cf13549b 100644
--- a/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt
+++ b/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt
@@ -19,12 +19,11 @@ import javax.inject.Inject
@HiltViewModel
class MyPageViewModel @Inject constructor(
private val userRepository: UserRepository,
- private val sessionStore: SessionStore, //세션 스토어 추가.
private val authPreference: AuthPreference
): ViewModel() {
// 별도 로딩(api 호출 없이) 로컬에 저장된 데이터 바로 보여줌.
- val sessionState: StateFlow = sessionStore.session
+ val sessionState = userRepository.sessionState
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
@@ -88,7 +87,7 @@ class MyPageViewModel @Inject constructor(
val id = authPreference.userId ?: return
viewModelScope.launch {
runCatching {
- userRepository.getUserInfo(id)
+ userRepository.refreshUserInfo(id)
}.onFailure { e ->
Log.e("MyPageViewModel", "데이터 동기화 실패: ${e.message}")
}
@@ -110,20 +109,16 @@ class MyPageViewModel @Inject constructor(
) {
viewModelScope.launch {
try {
- // 1) DB 변경 : UserRepository를 통해 PATCH /api/users/profile API 호출
- val success = userRepository.updateUserInfo(nickname, jobId, purposes, interests)
- if (success) {
- // 다시 fetch 해서 최신 데이터 반영
- //loadUserInfo()
-
- // 서버 성공 시(DB 변경 성공시) 세션만 즉시 업데이트(ui 자동 갱신)
- sessionStore.updateProfile(nickname, jobId, jobName, purposes, interests)
- onSuccess()
- } else {
- onError("변경에 실패했습니다.")
- }
+ userRepository.updateUserProfile(
+ nickname = nickname,
+ jobId = jobId,
+ jobName = jobName,
+ purposes = purposes,
+ interests = interests
+ )
+ onSuccess()
} catch (e: Exception) {
- onError("API 호출 실패: ${e.message}")
+ onError("변경에 실패했습니다: ${e.message}")
}
}
}
@@ -140,9 +135,6 @@ class MyPageViewModel @Inject constructor(
userRepository.deleteUser(reason)
Log.d("MyPageViewModel", "✅ 회원 탈퇴 성공")
- // 토큰/세션 정리
- authPreference.clear()
- sessionStore.clear()
onSuccess()
} catch (e: Exception) {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f315805b..7d2e14a5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -34,6 +34,9 @@ foundation = "1.9.5"
composeTesting = "1.0.0-alpha09"
toolsCore = "1.0.0-alpha14"
foundationVersion = "1.10.1"
+foundationLayout = "1.10.0"
+foundationLayoutVersion = "1.10.2"
+
[libraries]
@@ -84,6 +87,9 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f
androidx-compose-testing = { group = "androidx.xr.compose", name = "compose-testing", version.ref = "composeTesting" }
androidx-tools-core = { group = "androidx.privacysandbox.tools", name = "tools-core", version.ref = "toolsCore" }
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" }
+androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
+androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayoutVersion" }
+