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