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 8d96e885..6b351f8e 100644 --- a/app/src/main/java/com/example/linku_android/MainApp.kt +++ b/app/src/main/java/com/example/linku_android/MainApp.kt @@ -87,8 +87,11 @@ import com.example.login.viewmodel.LoginViewModel import dagger.hilt.android.EntryPointAccessors import androidx.core.net.toUri import com.example.curation.CurationDetailViewModel +import com.example.linku_android.curation.curationGraph 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 @Composable @@ -109,6 +112,7 @@ fun MainApp( // 홈 화면에서 사용할 뷰모델 val homeViewModel: HomeViewModel = hiltViewModel() + // 로그인 혹은 자동 로그인 성공 후 생성함. 여기서는 이미 AuthPreference 주입 끝. // 파일 화면에서 사용할 뷰모델 val fileViewModel: FileViewModel = hiltViewModel() @@ -120,6 +124,9 @@ fun MainApp( // 딥링크 접속 시 사용할 뷰모델 val deepLinkViewModel: DeepLinkHandlerViewModel = hiltViewModel() + // 마이페이지에서 사용할 뷰모델 + val mypageViewModel: MyPageViewModel = hiltViewModel() + var currentLinkuNavigationItem by remember { mutableStateOf(null) } var showNavBar by remember { mutableStateOf(false) } @@ -216,23 +223,43 @@ fun MainApp( setNavGraph { LaunchedEffect(Unit) { showNavBar = false } + var autoLoginTried by rememberSaveable { + mutableStateOf(false) + } + Splash( onResult = { val auth = deps.authPreference() + //스플래쉬에서 자동 로그인 조건 = refresh 토큰 존재 여부 확인 + //자동 로그인 판단을 여기서 한다고 생각하면 됨. val hasRefresh = !auth.refreshToken.isNullOrBlank() + // 이미 자동 로그인 시도했으면 강제 로그인 + if (autoLoginTried) { + navigator.navigate("login_root") { + popUpTo(NavigationRoute.Splash.route) { inclusive = true } + } + return@Splash + } + if (!hasRefresh) { // refresh 없음 → 로그인 화면으로 이동 - navigator.navigate("auth_graph") { + navigator.navigate("login_root") { popUpTo(NavigationRoute.Splash.route) { inclusive = true } - launchSingleTop = true } +// navigator.navigate("auth_graph") { +// popUpTo(NavigationRoute.Splash.route) { inclusive = true } +// launchSingleTop = true +// } return@Splash } + //수정! + autoLoginTried = true + // refresh 있음 → 자동로그인 시도 loginVM.tryAutoLogin( onSuccess = { @@ -242,10 +269,13 @@ fun MainApp( } }, onFail = { - navigator.navigate("auth_graph") { + navigator.navigate("login_root") { popUpTo(NavigationRoute.Splash.route) { inclusive = true } - launchSingleTop = true } +// navigator.navigate("auth_graph") { +// popUpTo(NavigationRoute.Splash.route) { inclusive = true } +// launchSingleTop = true +// } } ) } @@ -253,259 +283,42 @@ fun MainApp( } } + composable("login_root") { + LoginApp( + //navController = navigator, + loginViewModel = loginViewModel, + showNavBar = { showNavBar = it }, + onLoginSuccess = { + // 세선 정보가 저장 후, 홈 화면 데이터 즉시 로드 + homeViewModel.refreshAfterLogin() + // 마이페이지 정보도 미리 로그(자연스럽게?) + mypageViewModel.refreshUserInfo() - navigation( - route = "auth_graph", - startDestination = NavigationRoute.Login.route - ) { - - /* ① Login composable */ - composable(NavigationRoute.Login.route) { parentEntry -> - val signUpVm: SignUpViewModel = hiltViewModel(parentEntry) - - val skipAnimation = - parentEntry.savedStateHandle - .get("skip_login_animation") == true - - // 읽은 직후 초기화 - LaunchedEffect(skipAnimation) { - if (skipAnimation) { - parentEntry.savedStateHandle["skip_login_animation"] = false - } - } - AnimatedLoginScreen( - navigator = navigator, - skipAnimation = skipAnimation, - onSignUpClick = { - parentEntry.savedStateHandle["show_terms_sheet"] = true - } - ) - } - - /* ② Service Terms */ - composable("terms/service") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - //시스템 백버튼 처리 - BackHandler { - parentEntry.savedStateHandle["show_terms_sheet"] = true - navigator.popBackStack() - } - - ServiceTermsScreen( - onBackClicked = { - parentEntry.savedStateHandle["show_terms_sheet"] = true - navigator.popBackStack() - }, - onAgreeClicked = { - vm.setAgreeTerms(true) - parentEntry.savedStateHandle["show_terms_sheet"] = true - navigator.popBackStack() - } - ) - } - - /* ③ Privacy Terms */ - composable("terms/privacy") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - PrivacyTermsScreenFixed( - onBackClicked = { - parentEntry.savedStateHandle["show_terms_sheet"] = true - navigator.popBackStack() - }, - onAgreeClicked = { - vm.setAgreePrivacy(true) - parentEntry.savedStateHandle["show_terms_sheet"] = true - navigator.popBackStack() - } - ) - } - - /* ④ Marketing Terms */ - composable("terms/marketing") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - MarketingTermsScreenComposable( - onBackClicked = { - parentEntry.savedStateHandle["show_terms_sheet"] = true - navigator.popBackStack() - }, - onAgreeClicked = { - vm.setAgreeMarketing(true) - parentEntry.savedStateHandle["show_terms_sheet"] = true - navigator.popBackStack() - } - ) - } - - // 이메일 인증 - composable("email_verification") { entry -> - - val parentEntry = remember(entry) { - navigator.getBackStackEntry("auth_graph") - } - - - val vm: SignUpViewModel = hiltViewModel(parentEntry) - //백버튼으로 온 경우 애니메이션 적용X - BackHandler { - // 로그인 화면(AnimatedLoginScreen)에 애니메이션 스킵 플래그 전달함. - parentEntry.savedStateHandle["skip_login_animation"] = true - parentEntry.savedStateHandle["from_email_verification"] = true - - navigator.popBackStack() - } - - EmailVerificationScreen( - navigator = navigator, - parentEntry = parentEntry, // ⬅ 추가 - signUpViewModel = vm - ) - } - //ViewModel 사용 - // 비밀번호 - composable("sign_up_password") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - SignUpPasswordScreen(navigator = navigator, signUpViewModel = vm) - } - - // 닉네임 - composable("sign_up_nickname") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - SignUpNicknameScreen(navigator = navigator, signUpViewModel = vm) - } - - // 성별 - composable("sign_up_gender") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - SignUpGenderScreen(navigator = navigator, signUpViewModel = vm) - } - - // 직업 - composable("sign_up_job") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - SignUpJobScreen(navigator = navigator, signUpViewModel = vm) - } - - // 목적 - composable("sign_up_purpose") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - InterestPurposeScreen(navigator = navigator, signUpViewModel = vm) - } - - // 관심사 - composable("sign_up_interest") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - InterestContentScreen(navigator = navigator, signUpViewModel = vm) - } - - // 환영 화면 - composable("welcome") { entry -> - val parentEntry = remember(entry) { navigator.getBackStackEntry("auth_graph") } - val vm: SignUpViewModel = hiltViewModel(parentEntry) - - WelcomeScreen(navigator = navigator, signUpViewModel = vm) - } - - composable("email_login") { - - val parentEntry = remember(navigator.currentBackStackEntry) { - navigator.getBackStackEntry("auth_graph") - } - - val showTermsSheet by parentEntry.savedStateHandle - .getStateFlow("show_terms_sheet", false) - .collectAsStateWithLifecycle() - - //약관 바텀시트 떠 있을 때 백버튼 = 시트 닫기 - BackHandler(enabled = showTermsSheet) { - parentEntry.savedStateHandle["show_terms_sheet"] = false - } - - LaunchedEffect(Unit) { showNavBar = false } - - // 로그인 상태 관찰 - val loginState by loginViewModel.loginState.collectAsStateWithLifecycle() - - // 로그인 성공 시 즉시 재로드 - LaunchedEffect(loginState) { - val loggedIn = (loginState.result != null) && - (loginState.errorTag == null) && - !loginState.loading - if (loggedIn) { - // 큐레이션 재시도 가능하게 잠금 해제 후 로드 - curationViewModel.invalidate() - curationViewModel.loadMonthlyCuration() - - homeViewModel.refreshAfterLogin() - // (최소 변경 원하시면: homeViewModel.loadUserBasics(); homeViewModel.loadRecentLinks()) + showNavBar = true + currentLinkuNavigationItem = LinkuNavigationItem.HOME - // 필요하면 Home/File 등 다른 화면도 같은 패턴으로 리프레시 트리거 + // 딥링크 대기 작업 처리 //지민아 이거 정리해줄 수 있어? + deepLinkViewModel.consumePendingShare()?.let { folderId -> + fileViewModel.receiveSharedFolder(folderId) + folderStateViewModel.updateIsSharedFolders(true) - // 그리고 홈으로 이동 - navigator.navigate(NavigationRoute.Home.route) { - popUpTo(NavigationRoute.Login.route) { inclusive = true } + navigator.navigate(NavigationRoute.File.route) { + popUpTo("login_root") { inclusive = true } launchSingleTop = true } + return@LoginApp } - } - EmailLoginScreen( - loginViewModel = loginViewModel, - navigator = navigator, - onSignUpClick = { - parentEntry.savedStateHandle["show_terms_sheet"] = true - } - ) - // EmailLogin 위에서 바텀 시트 렌더 - TermsAgreementSheet( - navController = navigator, - vm = hiltViewModel(parentEntry), - visible = showTermsSheet, - onClose = { - parentEntry.savedStateHandle["show_terms_sheet"] = false - }, - onClickTerms = { - parentEntry.savedStateHandle["show_terms_sheet"] = false - navigator.navigate("terms/service") - }, - onClickPrivacy = { - parentEntry.savedStateHandle["show_terms_sheet"] = false - navigator.navigate("terms/privacy") - }, - onClickMarketing = { - parentEntry.savedStateHandle["show_terms_sheet"] = false - navigator.navigate("terms/marketing") + navigator.navigate(NavigationRoute.Home.route) { + popUpTo("login_root") { inclusive = true } + launchSingleTop = true } - ) - } - - - //비밀번호 재설정 화면 - composable("resetPassword") { - LaunchedEffect(Unit) { showNavBar = false } - //FinishHandler() - ResetPasswordScreen(navigator = navigator) - } + } + ) } + with(NavigationRoute.Home) { setNavGraph { LaunchedEffect(Unit) { @@ -532,59 +345,11 @@ fun MainApp( } } - - navigation( - startDestination = NavigationRoute.Curation.route, // 예: "curation" - route = "curation_graph" // 그래프 스코프 이름 - ) { - // 리스트(하이라이트) 화면 - composable(NavigationRoute.Curation.route) { backStackEntry -> - LaunchedEffect(Unit) { - showNavBar = true - currentLinkuNavigationItem = LinkuNavigationItem.CURATION - } - - - // 그래프 스코프 BackStackEntry를 기억 - val parentEntry = remember(backStackEntry) { - navigator.getBackStackEntry("curation_graph") - } - //그래프 스코프의 VM (재컴포지션/탭 전환에도 동일 인스턴스 유지) - val curationVm: CurationViewModel = hiltViewModel(parentEntry) - - CurationScreen( - viewModel = curationVm, - onOpenDetail = { userId: Long, curationId: Long -> - navigator.navigate("curation_detail/$userId/$curationId") { - launchSingleTop = true - } - } - ) - } - - // 디테일 화면 - composable("curation_detail/{userId}/{curationId}") { backStack -> - val userId = backStack.arguments?.getString("userId")!!.toLong() - val curationId = backStack.arguments?.getString("curationId")!!.toLong() - - // 같은 그래프 스코프의 홈 VM은 parent에서 - val parentEntry = remember(backStack) { - navigator.getBackStackEntry("curation_graph") - } - val homeVm: CurationViewModel = hiltViewModel(parentEntry) - - // 디테일 VM은 "현재 destination(backStack)" 스코프에서 생성해야 함! - val detailVm: CurationDetailViewModel = hiltViewModel(backStack) - - CurationDetailScreen( - userId = userId, - curationId = curationId, - detailViewModel = detailVm, // 디테일 전용 VM - homeViewModel = homeVm, // 리스트 화면과 같은 CurationViewModel 공유 - onBack = { navigator.popBackStack() } - ) - } - } + // 큐레이션 파트 리팩토링 적용 + curationGraph( + navigator = navigator, + showNavBar = { showNavBar = it } + ) with(NavigationRoute.MyPage) { @@ -592,22 +357,35 @@ fun MainApp( LaunchedEffect(Unit) { showNavBar = true currentLinkuNavigationItem = LinkuNavigationItem.MY_PAGE + // 화면 진입 시 최신 정보 로드 + mypageViewModel.refreshUserInfo() + //mypageViewModel.loadUserInfo() } //FinishHandler() - val mypageViewModel: MyPageViewModel = hiltViewModel() + MyPageApp( viewModel = mypageViewModel, onLogoutToLogin = { showNavBar = false // 바텀바 끄기 currentLinkuNavigationItem = null + + homeViewModel.clearData()// 모든 홈 데이터를 초기화 - 이전 데이터 방지. // 🔐 토큰/세션은 ViewModel 쪽에서 이미 정리한 뒤, // 전역 스택을 지우고 로그인 루트로 이동 - navigator.navigate(NavigationRoute.Login.route) { - popUpTo(0) { inclusive = true } // 전체 스택 제거 + navigator.navigate("login_root") { + // 현재 내비게이션 그래프의 시작점(Splash 등)까지 모두 제거 + popUpTo(navigator.graph.findStartDestination().id) { + inclusive = true + } + //popUpTo(0) { inclusive = true } launchSingleTop = true } +// navigator.navigate(NavigationRoute.Login.route) { +// popUpTo(0) { inclusive = true } // 전체 스택 제거 +// launchSingleTop = true +// } } ) } @@ -721,7 +499,7 @@ fun MainApp( ) } - // 딥링크 접속 시, 로그인이 안됐을 때 로그인 화면 + // 딥링크 접속 시, 로그인이 안됐을 때 로그인 화면 -> 이걸 처리 어떻게 할지 몰라 일단 주석처리. composable( route = "${NavigationRoute.Login.route}?showModal={showModal}", arguments = listOf(navArgument("showModal") { type = NavType.BoolType; defaultValue = false }) @@ -788,16 +566,19 @@ fun MainApp( } } - // 로그인 상태는 화면에서 '수집'하고, 그 값을 Effect key로 사용 + // 로그인 상태는 화면에서 '수집'하고, 그 값을 Effect key로 사용 -> 정: sealed class로 변경했는데 문제 있으면 말씀해주세요. val loginState by loginViewModel.loginState.collectAsStateWithLifecycle() + //Log.d("MainApp", "loginState: $loginState") + Log.d("MainApp", "loginState: $loginState") LaunchedEffect(loginState) { - val loggedIn = (loginState.result != null) && (loginState.errorTag == null) && !loginState.loading - Log.d("MainApp", "loggedIn (deeplink): $loggedIn") +// val loggedIn = (loginState.result != null) && (loginState.errorTag == null) && !loginState.loading +// Log.d("MainApp", "loggedIn (deeplink): $loggedIn") - if (loggedIn) { +// if (loggedIn) { + if (loginState is LoginState.Success) { Log.d("MainApp", "로그인 완료 (deeplink)") // pending 공유 폴더가 있으면 처리 deepLinkViewModel.consumePendingShare()?.let { pendingFolderId -> diff --git a/core/src/main/java/com/example/core/model/LoginResult.kt b/core/src/main/java/com/example/core/model/LoginResult.kt index cc07e663..a8b32ec2 100644 --- a/core/src/main/java/com/example/core/model/LoginResult.kt +++ b/core/src/main/java/com/example/core/model/LoginResult.kt @@ -2,9 +2,8 @@ package com.example.core.model data class LoginResult( val userId: Int, - val token: String, + val accessToken: String, // token에서 수정 + val refreshToken: String, val status: String, val inactiveDate: String? = null ) -/* -도메인 모델링. 뷰 모델에서 사용하기 쉬움!*/ \ 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 5ff97769..b47c54a2 100644 --- a/core/src/main/java/com/example/core/repository/UserRepository.kt +++ b/core/src/main/java/com/example/core/repository/UserRepository.kt @@ -5,6 +5,34 @@ import com.example.core.model.TokenReissueResult import com.example.core.model.UserInfo +// 목적 (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 { suspend fun checkNickname(nickname: String): Boolean //suspend fun getNickname(userId: Long): String? diff --git a/core/src/main/java/com/example/core/session/SessionStore.kt b/core/src/main/java/com/example/core/session/SessionStore.kt index fb670df2..f4f1b9e6 100644 --- a/core/src/main/java/com/example/core/session/SessionStore.kt +++ b/core/src/main/java/com/example/core/session/SessionStore.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext @@ -12,8 +13,23 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.first +/** + * [지현이 사용법 요약] + * + * 1. 세션 데이터 읽기 (UI 표시용) + * - session.nickname, session.email, session.purposes 등 + * + * 2. 사용자 정보 수정 + * - MyPageViewModel.updateUserInfo() 호출하면 끝! + * - 서버 API + 세션 업데이트 모두 자동 처리됨 + * + * 3. 닉네임만 수정할 때 + * - sessionStore.updateNickname(nickname) 사용 가능 + */ // 파일 최상위에 위치해야 합니다. + private val Context.dataStore by preferencesDataStore(name = "session_prefs") @Singleton @@ -22,24 +38,22 @@ class SessionStore @Inject constructor( ) { private object Keys { val LOGGED_IN = booleanPreferencesKey("logged_in") - val USER_ID = stringPreferencesKey("user_id") + val USER_ID = longPreferencesKey("user_id") // String -> Long val USER_NICK = stringPreferencesKey("user_nickname") val USER_EMAIL = stringPreferencesKey("user_email") val USER_GENDER = stringPreferencesKey("user_gender") - val USER_JOB_ID = stringPreferencesKey("user_job_id") + val USER_JOB_ID = longPreferencesKey("user_job_id") // String -> Long val USER_JOB_NAME = stringPreferencesKey("user_job_name") - val USER_MY_LINKU = stringPreferencesKey("user_my_linku") - val USER_MY_FOLDER = stringPreferencesKey("user_my_folder") - val USER_MY_AI_LINKU = stringPreferencesKey("user_my_ai_linku") - } - - suspend fun setLoggedIn(value: Boolean) { - context.dataStore.edit { p -> p[Keys.LOGGED_IN] = value } + val USER_MY_LINKU = longPreferencesKey("user_my_linku") // String -> Long + val USER_MY_FOLDER = longPreferencesKey("user_my_folder") // String -> Long + val USER_MY_AI_LINKU = longPreferencesKey("user_my_ai_linku") // String -> Long + val USER_PURPOSES = stringPreferencesKey("user_purposes") // 마이페이지 수정을 위해 추가. + val USER_INTERESTS = stringPreferencesKey("user_interests") // 마이페이지 수정을 위해 추가. } /** 앱 시작 시 오토로그인 분기용 */ val isLoggedIn: Flow = - context.dataStore.data.map { prefs: Preferences -> + context.dataStore.data.map { prefs -> prefs[Keys.LOGGED_IN] ?: false } @@ -53,36 +67,70 @@ class SessionStore @Inject constructor( myLinku: Long, myFolder: Long, myAiLinku: Long, + purposes: List, // 추가 - 마이페이지 수정. + interests: List, ) { context.dataStore.edit { p -> p[Keys.LOGGED_IN] = true - p[Keys.USER_ID] = userId.toString() + p[Keys.USER_ID] = userId p[Keys.USER_NICK] = nickname p[Keys.USER_EMAIL] = email p[Keys.USER_GENDER] = gender - p[Keys.USER_JOB_ID] = jobId.toString() + p[Keys.USER_JOB_ID] = jobId p[Keys.USER_JOB_NAME] = jobName - p[Keys.USER_MY_LINKU] = myLinku.toString() - p[Keys.USER_MY_FOLDER] = myFolder.toString() - p[Keys.USER_MY_AI_LINKU] = myAiLinku.toString() + p[Keys.USER_MY_LINKU] = myLinku + p[Keys.USER_MY_FOLDER] = myFolder + p[Keys.USER_MY_AI_LINKU] = myAiLinku + p[Keys.USER_PURPOSES] = purposes.joinToString(",") //추가 - 마이페이지 수정. + p[Keys.USER_INTERESTS] = interests.joinToString(",") } } suspend fun clear() { context.dataStore.edit { p -> p[Keys.LOGGED_IN] = false - p.remove(Keys.USER_ID) - p.remove(Keys.USER_NICK) - p.remove(Keys.USER_EMAIL) - p.remove(Keys.USER_GENDER) - p.remove(Keys.USER_JOB_ID) - p.remove(Keys.USER_JOB_NAME) - p.remove(Keys.USER_MY_LINKU) - p.remove(Keys.USER_MY_FOLDER) - p.remove(Keys.USER_MY_AI_LINKU) + p.clear() // 모든 세션 데이터 한 번에 삭제 + } + } + + // TODO : 지현이에게 전달 + // 지현이를 위한 실시간 프로필 업데이트 지원 + // 프로필 수정 시 purposes/interests도 업데이트 + suspend fun updateProfile( + nickname: String, + jobId: Long, + jobName: String, + purposes: List, + interests: List + ) { + context.dataStore.edit { p -> + p[Keys.USER_NICK] = nickname + p[Keys.USER_JOB_ID] = jobId + p[Keys.USER_JOB_NAME] = jobName + p[Keys.USER_PURPOSES] = purposes.joinToString(",") + p[Keys.USER_INTERESTS] = interests.joinToString(",") } } + /** MyPageViewModel에서 사용방법 + * fun updateUserInfo(nickname: String, jobId: Long, jobName: String) { + * viewModelScope.launch { + * // 1. 서버 API 호출 (UserRepository) + * val isSuccess = userRepository.updateUserInfo(nickname, jobId, ...) + * + * if (isSuccess) { + * // 2. 서버 성공 시 세션 스토어만 업데이트 (이것만 하면 UI가 알아서 바뀜!) + * sessionStore.updateProfile(nickname, jobId, jobName) + * } + * } + * } + * */ + + // 닉네임만 수정할 때 -> TODO : 지현이에게 전달 + suspend fun updateNickname(nickname: String) { + context.dataStore.edit { p -> p[Keys.USER_NICK] = nickname } + } + data class SessionSnapshot( val loggedIn: Boolean, val userId: Long?, @@ -94,21 +142,25 @@ class SessionStore @Inject constructor( val myLinku: Long?, val myFolder: Long?, val myAiLinku: Long?, + val purposes: List, // 추가 - 마이페이지 수정을 위해. + val interests: List, ) val session: Flow = context.dataStore.data.map { p -> SessionSnapshot( - loggedIn = p[Keys.LOGGED_IN] ?: false, - userId = p[Keys.USER_ID]?.toLongOrNull(), - nickname = p[Keys.USER_NICK], - email = p[Keys.USER_EMAIL], - gender = p[Keys.USER_GENDER], - jobId = p[Keys.USER_JOB_ID]?.toLongOrNull(), - jobName = p[Keys.USER_JOB_NAME], - myLinku = p[Keys.USER_MY_LINKU]?.toLongOrNull(), - myFolder = p[Keys.USER_MY_FOLDER]?.toLongOrNull(), - myAiLinku = p[Keys.USER_MY_AI_LINKU]?.toLongOrNull(), + loggedIn = p[Keys.LOGGED_IN] ?: false, + userId = p[Keys.USER_ID], + nickname = p[Keys.USER_NICK], + email = p[Keys.USER_EMAIL], + gender = p[Keys.USER_GENDER], + jobId = p[Keys.USER_JOB_ID], + jobName = p[Keys.USER_JOB_NAME], + myLinku = p[Keys.USER_MY_LINKU], + myFolder = p[Keys.USER_MY_FOLDER], + myAiLinku = p[Keys.USER_MY_AI_LINKU], + purposes = p[Keys.USER_PURPOSES]?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList(), + interests = p[Keys.USER_INTERESTS]?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList(), ) } diff --git a/data/src/main/java/com/example/data/api/ServerApi.kt b/data/src/main/java/com/example/data/api/ServerApi.kt index 00af29e8..337a8453 100644 --- a/data/src/main/java/com/example/data/api/ServerApi.kt +++ b/data/src/main/java/com/example/data/api/ServerApi.kt @@ -1,13 +1,9 @@ package com.example.data.api - -import java.io.IOException -import com.example.core.error.TokenExpiredException -import com.example.data.api.dto.BaseResponse import com.example.data.api.dto.RefreshApi -import com.example.data.preference.AuthPreference -// --- 기존 ServerApi + RefreshApi 확장 (AuthApi 제거) --- + +// 모든 api를 ServerApi를 하나로 합치면 한 번의 조립으로 자동 상속이 가능함. interface ServerApi : UserApi, LinkuApi, @@ -17,40 +13,3 @@ interface ServerApi : CategoryApi, RefreshApi - - -// 이전 리프레쉬 없을 때, 혹시 오류 발생시 참고. -//suspend fun ServerApi.withCheck( -// getter: suspend ServerApi.() -> BaseResponse -//): T { -// val response = getter() -// if (!response.isSuccess) throw Exception(response.message) -// return response.result -//} -//// refreshToken 갱신 有 -////suspend fun ServerApi.withAuth( -//// authPreference: AuthPreference, -//// routine: suspend ServerApi.() -> BaseResponse, -////): T { -//// try { -//// return withCheck { routine() } -//// } catch (_: Exception) { -//// val response = withCheck { refreshToken(authPreference.refreshToken!!) } -//// authPreference.refreshToken = response.refreshToken!! -//// authPreference.accessToken = response.accessToken!! -//// return withCheck { routine() } -//// } -////} -// -//// 토큰 자동 갱신이 불가능하므로 단순 실패 처리 (→ 로그인 페이지로 유도) -//suspend fun ServerApi.withAuth( -// authPreference: AuthPreference, -// routine: suspend ServerApi.() -> BaseResponse -//): T { -// return try { -// withCheck { routine() } -// } catch (e: Exception) { -// // refreshToken 없이 처리 → 로그인 유도 -// throw TokenExpiredException("Access token expired. Please log in again.") -// } -//} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/api/ServerApiExt.kt b/data/src/main/java/com/example/data/api/ServerApiExt.kt index 493b3e35..4f7eda23 100644 --- a/data/src/main/java/com/example/data/api/ServerApiExt.kt +++ b/data/src/main/java/com/example/data/api/ServerApiExt.kt @@ -7,193 +7,308 @@ import com.example.data.api.dto.server.RefreshTokenRequest import com.example.data.preference.AuthPreference import retrofit2.Response import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import retrofit2.Call import retrofit2.HttpException import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import java.util.concurrent.TimeUnit +// 추후 기능 확장용 - 네트웤, 서버 오류시 모달창에 활용 -> 기능 확장용 +sealed class ApiError : Exception() { + + // 인증 관련 + data class Unauthorized( + override val message: String = "인증이 필요합니다" + ) : ApiError() + + data class TokenExpired( + override val message: String = "로그인이 만료되었습니다" + ) : ApiError() + + data class Forbidden( + override val message: String = "접근 권한이 없습니다" + ) : ApiError() + + // 클라이언트 에러 + data class NotFound( + override val message: String = "요청한 데이터를 찾을 수 없습니다" + ) : ApiError() + + data class BadRequest( + val code: Int, + override val message: String + ) : ApiError() + + // 서버 에러 + data class ServerError( + val code: Int, + override val message: String = "서버 오류가 발생했습니다" + ) : ApiError() + + // 네트워크 에러 + data class NetworkError( + override val message: String = "네트워크 연결을 확인해주세요" + ) : ApiError() + + // 비즈니스 로직 에러 + data class BusinessError( + val errorCode: String?, + override val message: String + ) : ApiError() +} + +// TODO : 나중에 뷰모델에서 에러 문구 띄우기, 밑에는 활용 예시. +/*viewModelScope.launch { + try { + val result = serverApi.withAuth(authPreference) { getUserProfile() } + _state.value = Success(result) + } catch (e: ApiError) { + // 모달창으로 보여주기 + when (e) { + is ApiError.TokenExpired -> _errorEvent.emit(ErrorType.LOGIN_REQUIRED) + is ApiError.NetworkError -> _errorEvent.emit(ErrorType.NETWORK) + is ApiError.ServerError -> _errorEvent.emit(ErrorType.SERVER) + is ApiError.BusinessError -> _errorEvent.emit(ErrorType.BUSINESS(e.message)) + else -> _errorEvent.emit(ErrorType.UNKNOWN) + } + } +} +*/ + +// 2. 에러 변환 유틸 + +/** + * HttpException → ApiError 변환 + */ +private fun HttpException.toApiError(): ApiError = when (code()) { + 400 -> ApiError.BadRequest(code(), message()) + 401 -> ApiError.Unauthorized() + 403 -> ApiError.Forbidden() + 404 -> ApiError.NotFound() + in 500..599 -> ApiError.ServerError(code(), message()) + else -> ApiError.BadRequest(code(), message()) +} + +/** + * 일반 Exception → ApiError 변환 + * TODO : 다인 언니로부터 멘트 확정시 수정하기. + */ +private fun Exception.toApiError(): ApiError = when (this) { + is ApiError -> this + is HttpException -> toApiError() + is TokenExpiredException -> ApiError.TokenExpired(message ?: "토큰이 만료되었습니다") + is UnknownHostException -> ApiError.NetworkError() + is SocketTimeoutException -> ApiError.NetworkError("연결 시간이 초과되었습니다") + is IOException -> ApiError.NetworkError() + else -> ApiError.ServerError(0, message ?: "알 수 없는 오류가 발생했습니다") +} + +/** + * 401 여부 판단 (refresh 필요 여부) + */ +private fun shouldRefreshToken(e: Exception): Boolean = when (e) { + is HttpException -> e.code() == 401 + is TokenExpiredException -> true + is ApiError.Unauthorized -> true + is ApiError.TokenExpired -> true + else -> false +} + + +// 3. 토큰 갱신 로직 + +private val refreshMutex = Mutex() + +private suspend fun ServerApi.refreshTokenIfNeeded( + authPreference: AuthPreference +) = refreshMutex.withLock { + val refresh = authPreference.refreshToken + ?: throw ApiError.TokenExpired("다시 로그인해주세요") + + val pair = withCheck { refreshToken(RefreshTokenRequest(refresh)) } + pair.refreshToken?.let { authPreference.refreshToken = it } + pair.accessToken?.let { authPreference.accessToken = it } +} + +/** + * 토큰 갱신 + 1회 재시도 + 에러 변환 + */ +private suspend fun ServerApi.withTokenRefresh( + authPreference: AuthPreference, + block: suspend () -> T +): T { + return try { + block() + } catch (e: Exception) { + if (shouldRefreshToken(e)) { + try { + refreshTokenIfNeeded(authPreference) + block() // 1회 재시도 + } catch (refreshError: Exception) { + throw refreshError.toApiError() + } + } else { + throw e.toApiError() + } + } +} + +/** + * BaseResponse 검증 - isSuccess 확인 후 result 반환 + */ suspend fun ServerApi.withCheck( getter: suspend ServerApi.() -> BaseResponse ): T { val response = getter() - if (!response.isSuccess) throw Exception(response.message) + if (!response.isSuccess) { + throw ApiError.BusinessError( + errorCode = response.code, + message = response.message ?: "요청 처리 중 오류가 발생했습니다" + ) + } return response.result } +/** + * 인증 불필요 + BaseResponse 반환 + */ +suspend fun ServerApi.withErrorHandling( + block: suspend ServerApi.() -> BaseResponse +): T { + return try { + withCheck { block() } + } catch (e: Exception) { + throw e.toApiError() + } +} + +/** + * 인증 불필요 + 임의 타입 반환 (BaseResponse 아닌 경우) + * 예: ApiResponseString, Unit 등 + */ +suspend fun ServerApi.withErrorHandlingRaw( + block: suspend ServerApi.() -> T +): T { + return try { + block() + } catch (e: Exception) { + throw e.toApiError() + } +} + + -// refreshToken 갱신 (로그인 로직 변경 없음) +/** + * 기본 인증 API 호출 - BaseResponse + */ suspend fun ServerApi.withAuth( authPreference: AuthPreference, routine: suspend ServerApi.() -> BaseResponse, -): T { - // 1차 시도 - val first = runCatching { withCheck { routine() } } - if (first.isSuccess) return first.getOrThrow() +): T = withTokenRefresh(authPreference) { + withCheck { routine() } +} - // 실패 원인 판별 - val cause = first.exceptionOrNull() +/** + * 인증 필요 + 임의 타입 반환 - baseResponse 아닌 경우. + * */ +suspend fun ServerApi.withAuthRaw( + authPreference: AuthPreference, + routine: suspend ServerApi.() -> T +): T = withTokenRefresh(authPreference) { + routine() +} - val shouldRefresh = when (cause) { - is retrofit2.HttpException -> cause.code() == 401 // HTTP 401 - is TokenExpiredException -> true // 명시적 토큰 만료 - else -> false // 그 외는 리프레시 X +/** + * 인증 필요 + Unit 반환 (logout 등) + */ +suspend fun ServerApi.withAuthUnit( + authPreference: AuthPreference, + routine: suspend ServerApi.() -> Unit +) { + withTokenRefresh(authPreference) { + routine() } +} - if (!shouldRefresh) throw cause ?: Exception("Unknown error") - - // 리프레시 토큰 확인 - val refresh = authPreference.refreshToken - ?: throw TokenExpiredException("Access token expired. Please log in again.") - - // 1회 리프레시 - val pair = withCheck { refreshToken(RefreshTokenRequest(refresh)) } - pair.refreshToken?.let { authPreference.refreshToken = it } - pair.accessToken ?.let { authPreference.accessToken = it } - // 최종 1회 재시도 (여기서도 실패하면 그대로 예외 throw) - return withCheck { routine() } -} /** - * 204 No Content 를 정상 케이스(null)로 처리하는 withAuth 변형 - * routine 은 retrofit2.Response> 를 반환해야 함 + * 204 No Content 허용 버전 */ -// 204를 null로 처리하되, 응답 바디가 "그 자체 DTO"인 경우용 suspend fun ServerApi.withAuthResp204Raw( authPreference: AuthPreference, routine: suspend ServerApi.() -> Response ): T? { - // 1차 시도 - val r1 = routine() - if (r1.code() == 204) return null - if (r1.code() == 401) { - // 401에서만 refresh - val refresh = authPreference.refreshToken - ?: throw TokenExpiredException("Access token expired. Please log in again.") - val pair = withCheck { refreshToken(RefreshTokenRequest(refresh)) } - pair.refreshToken?.let { authPreference.refreshToken = it } - pair.accessToken ?.let { authPreference.accessToken = it } - - val r2 = routine() - if (r2.code() == 204) return null - if (!r2.isSuccessful) throw HttpException(r2) - return r2.body() ?: throw Exception("Empty body") + suspend fun execute(): T? { + val response = routine() + return when { + response.code() == 204 -> null + response.isSuccessful -> response.body() ?: throw ApiError.ServerError( + code = response.code(), + message = "Empty response body" + ) + else -> throw HttpException(response) + } } - if (!r1.isSuccessful) throw HttpException(r1) - return r1.body() ?: throw Exception("Empty body") -} -//suspend fun ServerApi.withAuthResp204Raw( -// authPreference: AuthPreference, -// routine: suspend ServerApi.() -> Response -//): T? { -// try { -// val r1 = routine() -// if (r1.code() == 204) return null -// val b1 = r1.body() ?: throw Exception("Empty body") -// return b1 -// } catch (_: Exception) { -// val refresh = authPreference.refreshToken -// ?: throw TokenExpiredException("Access token expired. Please log in again.") -// val pair = withCheck { refreshToken(RefreshTokenRequest(refresh)) } -// pair.refreshToken?.let { authPreference.refreshToken = it } -// pair.accessToken?.let { authPreference.accessToken = it } -// -// val r2 = routine() -// if (r2.code() == 204) return null -// val b2 = r2.body() ?: throw Exception("Empty body") -// return b2 -// } -//} + + return withTokenRefresh(authPreference) { execute() } +} /** - * Authorization 헤더 문자열("Bearer xxx")을 routine 에 넘기고 - * 임의 타입 T 를 그대로 반환. 401/만료 시 refresh 후 1회 재시도. + * 수동 헤더 전달 버전 - Interceptor 안 쓰는 API용 */ suspend fun ServerApi.withAuthHeaderRaw( authPreference: AuthPreference, routine: suspend ServerApi.(String) -> T -): T { +): T = withTokenRefresh(authPreference) { val access = authPreference.accessToken - ?: throw TokenExpiredException("Access token missing. Please log in.") - - // 1차 시도 - return try { - routine("Bearer $access") - } catch (e: Exception) { - val shouldRefresh = when (e) { - is HttpException -> e.code() == 401 - is TokenExpiredException -> true - else -> false - } - if (!shouldRefresh) throw e - - val refresh = authPreference.refreshToken - ?: throw TokenExpiredException("Access token expired. Please log in again.") - val pair = withCheck { refreshToken(RefreshTokenRequest(refresh)) } - pair.refreshToken?.let { authPreference.refreshToken = it } - pair.accessToken ?.let { authPreference.accessToken = it } - - val access2 = authPreference.accessToken - ?: throw TokenExpiredException("Failed to refresh token.") - routine("Bearer $access2") // 최종 1회 재시도 - } + ?: throw ApiError.TokenExpired("다시 로그인해주세요") + routine("Bearer $access") } -//suspend fun ServerApi.withAuthHeaderRaw( -// authPreference: AuthPreference, -// routine: suspend ServerApi.(String) -> T -//): T { -// try { -// val access = authPreference.accessToken -// ?: throw TokenExpiredException("Access token missing. Please log in.") -// return routine("Bearer $access") -// } catch (_: Exception) { -// val refresh = authPreference.refreshToken -// ?: throw TokenExpiredException("Access token expired. Please log in again.") -// val pair = withCheck { refreshToken(RefreshTokenRequest(refresh)) } -// pair.refreshToken?.let { authPreference.refreshToken = it } -// pair.accessToken?.let { authPreference.accessToken = it } -// -// val access2 = authPreference.accessToken -// ?: throw TokenExpiredException("Failed to refresh token.") -// return routine("Bearer $access2") -// } -//} - -// --- Call 전용: per-call timeout + 401시 refresh 후 1회 재시도 (Raw T 반환) --- + +/** + * Call 기반 + Timeout + */ suspend fun ServerApi.withAuthCallRaw( authPreference: AuthPreference, timeoutSec: Long = 60, build: ServerApi.() -> Call ): T = withContext(Dispatchers.IO) { - fun Call.execWithTimeout(): Response { - timeout().timeout(timeoutSec, TimeUnit.SECONDS) // ← 이 호출만 타임아웃 - return execute() - } - - var resp = build().execWithTimeout() - // 401이면 refresh 후 1회 재시도 - if (resp.code() == 401) { - val refresh = authPreference.refreshToken - ?: throw TokenExpiredException("Access token expired. Please log in again.") - val pair = withCheck { refreshToken(RefreshTokenRequest(refresh)) } - pair.refreshToken?.let { authPreference.refreshToken = it } - pair.accessToken?.let { authPreference.accessToken = it } + suspend fun execute(): T { + val response = build().apply { + timeout().timeout(timeoutSec, TimeUnit.SECONDS) + }.execute() - resp = build().execWithTimeout() // Call은 재사용 불가 → 새로 build() + if (!response.isSuccessful) throw HttpException(response) + return response.body() ?: throw ApiError.ServerError( + code = response.code(), + message = "Empty response body" + ) } - if (!resp.isSuccessful) throw HttpException(resp) - resp.body() ?: throw IOException("Empty body") + withTokenRefresh(authPreference) { execute() } } -// --- BaseResponse를 받아 isSuccess 확인 후 R만 반환 --- +/** + * Call + BaseResponse 검증 + */ suspend fun ServerApi.withAuthCallChecked( authPreference: AuthPreference, timeoutSec: Long = 60, build: ServerApi.() -> Call> ): R = withContext(Dispatchers.IO) { val base = withAuthCallRaw(authPreference, timeoutSec, build) - if (!base.isSuccess) throw Exception(base.message) - base.result ?: throw IOException("Empty result") -} \ No newline at end of file + if (!base.isSuccess) { + throw ApiError.BusinessError( + errorCode = base.code, + message = base.message ?: "요청 처리 중 오류가 발생했습니다" + ) + } + base.result ?: throw ApiError.ServerError(0, "Empty result") +} + + diff --git a/data/src/main/java/com/example/data/di/api/ServerApiModule.kt b/data/src/main/java/com/example/data/di/api/ServerApiModule.kt index 126238f1..8b7a61a1 100644 --- a/data/src/main/java/com/example/data/di/api/ServerApiModule.kt +++ b/data/src/main/java/com/example/data/di/api/ServerApiModule.kt @@ -14,72 +14,102 @@ import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Singleton import com.example.data.BuildConfig import com.example.data.api.UserApi +import okhttp3.Interceptor + +/* +* 토큰을 읽는 첫 번째 지점임. +* AuthPreferenceImpl에 저장된 토큰 꺼내서 사용하는 곳. +* */ @Module -@InstallIn(SingletonComponent::class) +@InstallIn(SingletonComponent::class) //앱이 꺼질 때까지 하나만 만들어서 어디서든 돌려쓸 예쩡임. object ServerApiModule { + + // 요청/응답 로그 출력 @Provides @Singleton - fun provideServerApi( - authPreference: AuthPreference, - moshi: Moshi, - ): ServerApi { - val logger = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY + fun provideLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } } + } + + // 인증 인터센터임. 토큰에 헤더를 자동 추가함. + @Provides + @Singleton + fun provideAuthInterceptor(authPreference: AuthPreference): Interceptor { + return Interceptor { chain -> + val originalRequest = chain.request() + val path = originalRequest.url.encodedPath + + // UserApi 정의서에 기반한 토큰 미필요 경로 리스트 (정확한 경로 명시) + val skipAuthPaths = setOf( + "/api/users/reissue", + "/api/users/login", + "/api/users/join", + "/api/users/check-nickname", + "/api/users/emails/code", + "/api/users/emails/verify", + "/api/users/password/temp" + ) - val client = OkHttpClient.Builder() - .addNetworkInterceptor { - val request = it.request() - .newBuilder() - .let { builder -> - authPreference.accessToken?.let { token -> - builder.addHeader("Authorization", "Bearer $token") - } ?: builder + // path가 "/api/users/login"일 때만 true가 됨 + val isSkipPath = skipAuthPaths.contains(path) + + val newRequest = if (isSkipPath) { + originalRequest + } else { + originalRequest.newBuilder().apply { + authPreference.accessToken?.let { token -> + addHeader("Authorization", "Bearer $token") } - .build() - it.proceed(request) + }.build() } - .addInterceptor(logger) + chain.proceed(newRequest) + } + } + + //OkHttpClient : 네트워크 전송 + @Provides + @Singleton + fun provideOkHttpClient( + authInterceptor: Interceptor, + loggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { + return OkHttpClient.Builder() + .addNetworkInterceptor(authInterceptor) // 토큰 붙이기 + .addInterceptor(loggingInterceptor) // 로그 출력 .build() + } + // retrofit : 한개 생성해서 공유함. + @Provides + @Singleton + fun provideRetrofit( + client: OkHttpClient, + moshi: Moshi + ): Retrofit { return Retrofit.Builder() .baseUrl(BuildConfig.SERVER_BASE_URL) .addConverterFactory(MoshiConverterFactory.create(moshi)) .client(client) .build() - .create(ServerApi::class.java) } - - // Retrofit을 주입받지 않고 직접 생성 (ServerApiModule 구조 유지) @Provides @Singleton - fun provideUserApi(authPreference: AuthPreference, moshi: Moshi): UserApi { - val logger = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } - - val client = OkHttpClient.Builder() - .addNetworkInterceptor { - val request = it.request() - .newBuilder() - .let { builder -> - authPreference.accessToken?.let { token -> - builder.addHeader("Authorization", "Bearer $token") - } ?: builder - } - .build() - it.proceed(request) - } - .addInterceptor(logger) - .build() + fun provideServerApi(retrofit: Retrofit): ServerApi { + return retrofit.create(ServerApi::class.java) + } - return Retrofit.Builder() - .baseUrl(BuildConfig.SERVER_BASE_URL) - .addConverterFactory(MoshiConverterFactory.create(moshi)) - .client(client) - .build() - .create(UserApi::class.java) + @Provides + @Singleton + fun provideUserApi(retrofit: Retrofit): UserApi { + return retrofit.create(UserApi::class.java) } + } \ No newline at end of file diff --git a/data/src/main/java/com/example/data/di/preference/AuthPreferenceModule.kt b/data/src/main/java/com/example/data/di/preference/AuthPreferenceModule.kt index d840182d..282fbaea 100644 --- a/data/src/main/java/com/example/data/di/preference/AuthPreferenceModule.kt +++ b/data/src/main/java/com/example/data/di/preference/AuthPreferenceModule.kt @@ -10,14 +10,18 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +// 로그인 토큰을 일고 쓸 수 있는 기능을 만들었음. @Module @InstallIn(SingletonComponent::class) +// 앱 전체에서 공유하는 레시피임. object AuthPreferenceModule { @Provides - @Singleton + @Singleton // 단 하나의 유일한 객체를 만들기 위한 코드 패턴. + // 실제 구현체는 AuthPreferenceImpl을 하나만 만들어서 줌. fun provideAuthPreference( @ApplicationContext context: Context + // 저장소를 만들려면 안드로이드 시스템 환경 정보인(컨텍스트)가 필요함. ): AuthPreference { - return AuthPreferenceImpl(context) + return AuthPreferenceImpl(context) //컨텍스트를 넣어서 실제 저장소를 조립했어. 이제 이걸 쓰도록 해. } } \ No newline at end of file diff --git a/data/src/main/java/com/example/data/implementation/preference/AuthPreferenceImpl.kt b/data/src/main/java/com/example/data/implementation/preference/AuthPreferenceImpl.kt index c648e474..e4d30ddf 100644 --- a/data/src/main/java/com/example/data/implementation/preference/AuthPreferenceImpl.kt +++ b/data/src/main/java/com/example/data/implementation/preference/AuthPreferenceImpl.kt @@ -1,55 +1,98 @@ package com.example.data.implementation.preference + import android.content.Context +import android.util.Log import com.example.data.preference.AuthPreference +/* +* 로그인/자동 로그인에 필요한 토큰 정보를 실제 SharedPreferences 저장. +* 모든 토큰을 저장하는 위치임. +* */ class AuthPreferenceImpl(context: Context) : AuthPreference { companion object { + // SharedPreferences 파일 이름 private const val PREF_NAME = "auth" private const val ACCESS_TOKEN_KEY = "access_token" private const val REFRESH_TOKEN_KEY = "refresh_token" + //EncryptedSharedPreferences을 적용을 고민했으나, coderabbitai에 피드백이 있어서 주석 남깁니다. + // 암호화로 나중에 오류 발생시 찾기 어려워 보이는데,어떻게 할지? + //TODO : 팀장에게 결정 부탁하기. private const val USER_ID_KEY = "user_id" - } + private const val TAG = "AuthPreferenceImpl" //태그 상수 정의 + } + // 실제 저장소. private val pref = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + // 모든 인증 api 요청에 사용하는 토큰, OkHttp NetworkInterceptor 읽고 Authorization 헤더에 붙음. override var accessToken: String? get() = pref.getString(ACCESS_TOKEN_KEY, null) set(value) { pref.edit().apply { - if (value == null) remove(ACCESS_TOKEN_KEY) else putString(ACCESS_TOKEN_KEY, value) + if (value == null) remove(ACCESS_TOKEN_KEY) + else putString(ACCESS_TOKEN_KEY, value) }.apply() } - + // 엑세스 토큰은 수명이 짧음. 재발급에 사용함. 스플래쉬에서 자동 로그인 가능 여부 판단의 기준임. + // 여기 값이 있음 -> 로그인 상태임 , 값이 없으면 로그인 화면임. override var refreshToken: String? get() = pref.getString(REFRESH_TOKEN_KEY, null) set(value) { pref.edit().apply { - if (value == null) remove(REFRESH_TOKEN_KEY) else putString(REFRESH_TOKEN_KEY, value) + //로그아웃 시 리프래쉬 토큰 제거함. -> 자동 로그인 깨짐. + if (value == null) remove(REFRESH_TOKEN_KEY) + else putString(REFRESH_TOKEN_KEY, value) }.apply() } override var userId: Long? - get() = if (!pref.contains(USER_ID_KEY)) { - null - } else { - pref.getLong(USER_ID_KEY, -1L).let { if (it == -1L) null else it } + get() { + // 키 자체가 없으면 → 유저 없음 + if (!pref.contains(USER_ID_KEY)) { + return null + } + + // 키가 있으면 값 가져오기 + val value = pref.getLong(USER_ID_KEY, -1L) + + // -1L이면 유효하지 않은 값 → null + return if (value == -1L) null else value } set(value) { pref.edit().apply { - if (value == null) remove(USER_ID_KEY) else putLong(USER_ID_KEY, value) + if (value == null) { + remove(USER_ID_KEY) + } else { + putLong(USER_ID_KEY, value) + } }.apply() } -// override var accessToken: String? -// get() = pref.getString(ACCESS_TOKEN_KEY, null) -// set(value) { pref.edit().putString(ACCESS_TOKEN_KEY, value).apply() } -// -// override var refreshToken: String? -// get() = pref.getString(REFRESH_TOKEN_KEY, null) -// set(value) { pref.edit().putString(REFRESH_TOKEN_KEY, value).apply() } -// -// override var userId: Long? -// get() = pref.getLong(USER_ID_KEY, -1L) -// set(value) { pref.edit().putLong(USER_ID_KEY, value ?: -1L).apply() } + // 모든 인증 정보 삭제(로그가웃, 회원탈퇴시 사용) + override fun clear() { + pref.edit() + .remove(ACCESS_TOKEN_KEY) + .remove(REFRESH_TOKEN_KEY) + .remove(USER_ID_KEY) + .apply() + + Log.d(TAG, "인증 정보 삭제 완료") + } + + // 토큰 저장(로그인 성공시) + override fun saveTokens( + accessToken: String, + refreshToken: String, + userId: Long + ) { + pref.edit() + .putString(ACCESS_TOKEN_KEY, accessToken) + .putString(REFRESH_TOKEN_KEY, refreshToken) + .putLong(USER_ID_KEY, userId) + .apply() + + Log.d(TAG, "토큰 저장 완료") + } + } \ No newline at end of file diff --git a/data/src/main/java/com/example/data/implementation/repository/LinkuRepositoryImpl.kt b/data/src/main/java/com/example/data/implementation/repository/LinkuRepositoryImpl.kt index 852ddc13..37a17f69 100644 --- a/data/src/main/java/com/example/data/implementation/repository/LinkuRepositoryImpl.kt +++ b/data/src/main/java/com/example/data/implementation/repository/LinkuRepositoryImpl.kt @@ -20,6 +20,11 @@ import okhttp3.RequestBody.Companion.toRequestBody import java.io.File import javax.inject.Inject +/* +* 여기는 인증이 필요한 모든 api의 시작으로 뷰모델은 토큰/인증/리프레쉬를 모르도록 설계함. +* 모든 인증처리는 여기 withAuth()에서 시작함. withAuth() 최초 호출 지점은 여기임. +* */ + class LinkuRepositoryImpl @Inject constructor( private val serverApi: ServerApi, private val authPreference: AuthPreference, @@ -137,7 +142,7 @@ class LinkuRepositoryImpl @Inject constructor( // val response = serverApi.withAuth(authPreference) { // recentLinks(limit = limit) // BaseResponse> // } - + //홈에서 가장 먼저 호출하는 api 여기서 withAuth 처음 진입함. val raw = serverApi.withAuth(authPreference) { recentLinks(limit = limit) } // BaseResponse / T(List) 둘 다 커버 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 6d9de1cb..5ec0011f 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 @@ -5,6 +5,8 @@ import com.example.core.model.LoginResult import com.example.core.model.TokenReissueResult import com.example.core.model.UserInfo import com.example.core.repository.UserRepository +import com.example.core.session.SessionStore +import com.example.data.api.ApiError import com.example.data.api.ServerApi import com.example.data.api.UserApi import com.example.data.api.dto.server.JoinDTO @@ -16,6 +18,9 @@ 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 @@ -24,78 +29,79 @@ import javax.inject.Inject class UserRepositoryImpl @Inject constructor( private val userApi: UserApi, private val serverApi: ServerApi, - private val authPreference: AuthPreference + private val authPreference: AuthPreference, + private val sessionStore: SessionStore ) : UserRepository { - // 서버 ENUM 매핑 (클래스 내부에 추가) + + // ENUM 매핑s private val purposeMap = mapOf( - "취업·커리어 준비" to "CAREER", - "학업/리포트 정리" to "STUDY", - "업무자료 아카이빙" to "WORK", - "사이드 프로젝트/창업 준비" to "SIDE_PROJECT", "자기계발/정보 수집" to "SELF_DEVELOPMENT", - "그냥 나중에 읽고 싶은 글 저장" to "LATER_READING", - "인사이트 모으기" to "INSIGHTS", + "사이드 프로젝트/창업 준비" to "SIDE_PROJECT", + "기타" to "OTHERS", + "그냥 나중에 읽고싶은 글 저장" to "LATER_READING", + "취업·커리어 준비" to "CAREER", "블로그/콘텐츠 작성 참고용" to "CREATION_REFERENCE", - "기타" to "OTHERS" + "인사이트 모으기" to "INSIGHTS", + "업무자료 아카이빙" to "WORK", + "학업/리포트 정리" to "STUDY" ) private val interestMap = mapOf( "비즈니스/마케팅" to "BUSINESS", - "IT/개발" to "IT", - "디자인/크리에이티브" to "DESIGN", - "심리/자기계발" to "PSYCHOLOGY", + "학업/리포트 참고" to "STUDY", "커리어/채용" to "CAREER", + "심리/자기계발" to "PSYCHOLOGY", + "디자인/크리에이티브" to "DESIGN", + "IT/개발" to "IT", + "글쓰기/콘텐츠 작성" to "WRITING", "시사/트렌드" to "CURRENT_EVENTS", - "학업/리포트 참고" to "STUDY", "스타트업/창업" to "STARTUP", + "그냥 모아두고 싶은 글들" to "COLLECT", "사회/문화/환경" to "SOCIETY", - "글쓰기/콘텐츠 작성" to "WRITING", - "책/인사이트 요약" to "INSIGHTS", - "그냥 모아두고 싶은 글들" to "COLLECT" + "책/인사이트 요약" to "INSIGHTS" ) - // ENUM -> 한글 (역매핑) private val reversePurposeMap = purposeMap.entries.associate { it.value to it.key } private val reverseInterestMap = interestMap.entries.associate { it.value to it.key } - override suspend fun checkNickname(nickname: String): Boolean { - return try { - Log.d("UserRepository", " [API 호출] checkNickname nickname=$nickname") - val response = userApi.checkNickname(nickname) - - Log.d("UserRepository", " [닉네임 API 응답] isSuccess=${response.isSuccess}, message=${response.message}, result=${response.result}") + // checkNickname - ApiResponseString 반환하므로 withErrorHandlingRaw 사용 + override suspend fun checkNickname(nickname: String): Boolean { + Log.d(TAG, "[API 호출] checkNickname nickname=$nickname") - // 서버 메시지에 따라 사용 가능 여부 결정 - response.isSuccess == true && response.result?.contains("사용 가능") == true - } catch (e: HttpException) { - Log.e("UserRepository", " [닉네임 API 오류] code=${e.code()} msg=${e.message()}") - false - } catch (e: Exception) { - Log.e("UserRepository", " [닉네임 API 호출 실패]", e) + return try { + val response = serverApi.withErrorHandlingRaw { + checkNickname(nickname) // ApiResponseString 반환 + } + // response가 ApiResponseString이면 그에 맞게 처리 + val isAvailable = response.isSuccess == true + Log.d(TAG, "[닉네임 API 응답] 사용가능=$isAvailable") + isAvailable + } catch (e: ApiError) { + Log.e(TAG, "[닉네임 API 오류] ${e.message}") false } } - override suspend fun login(email: String, password: String): LoginResult { - val response = userApi.signIn(LoginRequestDTO(email, password)) - val result = response.result ?: throw IllegalStateException("로그인 실패: ${response.message}") - // userId 저장 - authPreference.userId = result.userId?.toLong() ?: -1L + override suspend fun login(email: String, password: String): LoginResult { + Log.d(TAG, "[로그인 시도]") - // access/refresh 저장 (널/빈값 방어) - result.accessToken?.takeIf { it.isNotBlank() }?.let { authPreference.accessToken = it } - result.refreshToken?.takeIf { it.isNotBlank() }?.let { authPreference.refreshToken = it } // ⬅️ 추가 + // API 호출 및 결과 수신 + val response = serverApi.withErrorHandling { + signIn(LoginRequestDTO(email, password)) + } - // ⬇️ 리플렉션으로 refresh 찾던 블럭은 전부 제거하세요. + Log.d(TAG, "[로그인 성공]") - // (이하 기존 반환 로직 유지) return LoginResult( - userId = result.userId?.toInt() ?: -1, - token = result.accessToken ?: "", - status = result.status ?: "", - inactiveDate = result.inactiveDate?.toString() + userId = response.userId?.toInt() ?: throw IllegalStateException("로그인 응답에 userId가 누락되었습니다."), + accessToken = response.accessToken + ?: throw ApiError.BusinessError(null, "accessToken이 없습니다"), + refreshToken = response.refreshToken + ?: throw ApiError.BusinessError(null, "refreshToken이 없습니다"), + status = response.status ?: "", + inactiveDate = response.inactiveDate?.toString() ) } @@ -108,6 +114,7 @@ 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 } @@ -124,66 +131,136 @@ class UserRepositoryImpl @Inject constructor( interestList = safeInterestList ) - // 회원가입은 토큰 없이 호출해야 함 - val response = userApi.signUp(dto) - Log.d("UserRepository", " [회원가입 응답] isSuccess=${response.isSuccess} message=${response.message}") - - return response.isSuccess == true + serverApi.withErrorHandling { signUp(dto) } + Log.d(TAG, "[회원가입 성공]") + return true } + // ApiResponseString 반환 → withErrorHandlingRaw override suspend fun sendEmailCode(email: String, code: String): Boolean { - val response = userApi.sendVerificationEmail(email, code) - return response.isSuccess == true // Boolean? → Boolean 변환 + return try { + val response = serverApi.withErrorHandlingRaw { + sendVerificationEmail(email, code) + } + response.isSuccess == true + } catch (e: ApiError) { + Log.e(TAG, "[이메일 코드 전송 실패] ${e.message}") + false + } } + // BaseResponse 반환 → withErrorHandling override suspend fun verifyEmailCode(email: String, code: String): Boolean { - val response = userApi.checkVerificationEmail(email, code) - return response.isSuccess == true // Boolean? → Boolean 변환 + return try { + serverApi.withErrorHandling { checkVerificationEmail(email, code) } + true + } catch (e: ApiError) { + Log.e(TAG, "[이메일 코드 검증 실패] ${e.message}") + false + } } - //inactiveDate 추가. + // BaseResponse 반환 → withErrorHandling + override suspend fun reissue(refreshToken: String): TokenReissueResult { + Log.d(TAG, "[토큰 재발급 시도]") - override suspend fun deleteUser(reason: String): Boolean { - val dto = DeleteReasonDTO(reason) - val response = userApi.deleteUser(dto) - return response.isSuccess == true + val response = serverApi.withErrorHandling { + reissue(refreshToken) + } + + Log.d(TAG, "[토큰 재발급 성공]") + + return TokenReissueResult( + accessToken = response.accessToken + ?: throw ApiError.BusinessError(null, "accessToken이 없습니다"), + refreshToken = response.refreshToken + ?: throw ApiError.BusinessError(null, "refreshToken이 없습니다") + ) + } + + // ApiResponseString 반환 → withErrorHandlingRaw + override suspend fun requestTempPassword(email: String): Boolean { + Log.d(TAG, "[임시PW 요청] email=${email.take(3)}***") + // Log.d(TAG, "[임시PW 요청] email=$email") 보안 문제로 주석처리 단, 오류 발생시 사용해주세요. + + return try { + val response = serverApi.withErrorHandlingRaw { + requestTempPassword(email) + } + val success = response.isSuccess == true + Log.d(TAG, "[임시PW 요청 결과] success=$success") + success + } catch (e: ApiError) { + Log.e(TAG, "[임시PW 요청 실패] ${e.message}") + false + } } - // 마이페이지 조회 + + // 인증 필요 API (withAuth) + override suspend fun getUserInfo(userId: Long): UserInfo { - val response = userApi.getUserInfo(userId) + //val fullToken = authPreference.accessToken + //Log.d(TAG, "📍 Full AccessToken: $fullToken") + val dto = serverApi.withAuth(authPreference) { + getUserInfo(userId) + } + + // 📍 서버 원본 데이터 확인 + Log.d(TAG, "📍 [서버 원본] purposes: ${dto.purposes}") + Log.d(TAG, "📍 [서버 원본] interests: ${dto.interests}") - val dto = response.result - ?: throw IllegalStateException("마이페이지 조회 실패: ${response.message}") - // 서버 enum → 한글 + // 서버에서 온 ENUM(CAREER 등)을 UI용 한글("취업 커리어 준비")로 변환 val displayPurposes = dto.purposes.map { reversePurposeMap[it] ?: it } val displayInterests = dto.interests.map { reverseInterestMap[it] ?: it } + // 📍 변환 후 데이터 확인 + Log.d(TAG, "📍 [변환 후] purposes: $displayPurposes") + Log.d(TAG, "📍 [변환 후] interests: $displayInterests") + return UserInfo( - nickname = dto.nickName.orEmpty(), - email = dto.email, - gender = dto.gender.value, - jobId = dto.job.id, - jobName = dto.job.name, - myLinku = dto.myLinku, - myFolder = dto.myFolder, - myAiLinku = dto.myAiLinku, - purposes = displayPurposes, + nickname = dto.nickName.orEmpty(), + email = dto.email, + gender = dto.gender.value, + jobId = dto.job.id.toLong(), + jobName = dto.job.name, + myLinku = dto.myLinku.toLong(), + myFolder = dto.myFolder.toLong(), + myAiLinku = dto.myAiLinku.toLong(), + purposes = displayPurposes, interests = displayInterests - ) + ).also { userInfo -> + // 세션을 업데이트, 지현이가 편할 수 있게 + Log.d(TAG, "📍 [세션 저장] purposes: ${userInfo.purposes}") + Log.d(TAG, "📍 [세션 저장] interests: ${userInfo.interests}") + sessionStore.saveLogin( + userId = userId, + nickname = userInfo.nickname, + email = userInfo.email, + gender = userInfo.gender, + jobId = userInfo.jobId, + jobName = userInfo.jobName, + myLinku = userInfo.myLinku, + myFolder = userInfo.myFolder, + myAiLinku = userInfo.myAiLinku, + purposes = userInfo.purposes, + interests = userInfo.interests + + ) + Log.d(TAG, "📍 [세션 저장 완료]") + } } - // 마이페이지 계정 정보 수정 override suspend fun updateUserInfo( nickname: String, jobId: Long, purposes: List, interests: List ): Boolean { - // 한글 → ENUM 코드로 변환 - val mappedPurposes = purposes.mapNotNull { purposeMap[it] } - val mappedInterests = interests.mapNotNull { interestMap[it] } + // 수정 시에도 한글 -> ENUM 변환 후 전송 + val mappedPurposes = purposes.map { purposeMap[it] ?: it } + val mappedInterests = interests.map { interestMap[it] ?: it } val dto = UpdateProfileDTO( nickname = nickname, @@ -192,85 +269,56 @@ class UserRepositoryImpl @Inject constructor( interests = mappedInterests ) - val res = userApi.updateUserInfo(dto) - return res.isSuccess == true - } - // 로그아웃 - override suspend fun logout() { - // 서버 로그아웃: 401이면 refresh 후 1회 재시도 - runCatching { - serverApi.withAuthHeaderRaw(authPreference) { _ -> - // logout 이 suspend fun logout(): Unit 인 경우 - logout() - } - }.onFailure { e -> - Log.w("UserRepository", "logout API failed: ${e.message}") - // 서버 실패여도 로컬 세션은 아래에서 정리 + val response = serverApi.withAuthRaw(authPreference) { + updateUserInfo(dto) // ApiResponseString 반환 + } + + // response.isSuccess가 false라면 예외를 던지거나 false를 반환하도록 처리 + if (response.isSuccess != true) { + throw ApiError.BusinessError(null, response.result ?: "수정 실패") } - // 로컬 세션은 항상 정리 - authPreference.accessToken = null - authPreference.refreshToken = null - authPreference.userId = null + return true + } + + // BaseResponse 반환 → withAuth + override suspend fun deleteUser(reason: String): Boolean { + val dto = DeleteReasonDTO(reason) + serverApi.withAuth(authPreference) { deleteUser(dto) } + return true } - // 닉네임 전용 메서드로 분리 + // BaseResponse 반환 → withAuth override suspend fun getNickname(userId: Long): String? { return try { - val res = userApi.getUserInfo(userId) - val nick = res.result?.nickName - Log.d("UserRepository", "닉네임=$nick") + val dto = serverApi.withAuth(authPreference) { + getUserInfo(userId) + } + val nick = dto.nickName + Log.d(TAG, "닉네임=$nick") nick?.takeIf { it.isNotBlank() } - } catch (e: HttpException) { - if (e.code() == 500) null else throw e - } catch (e: Exception) { - Log.e("UserRepository", "닉네임 가져오기 실패", e) + } catch (e: ApiError) { + Log.e(TAG, "닉네임 가져오기 실패: ${e.message}") null } } - override suspend fun reissue(refreshToken: String): TokenReissueResult { - val response = userApi.reissue(refreshToken) - - if (response.isSuccess != true || response.result == null) { - throw Exception("Token reissue failed: ${response.message}") - } - - val r = response.result - - return TokenReissueResult( - accessToken = r.accessToken ?: "", - refreshToken = r.refreshToken ?: "" - ) + // logout? - TODO : 지현아... 세션으로 마이페이지 해야할 듯... + override suspend fun logout() { + clearAuthData() + Log.d(TAG, "로그아웃 완료") } + private suspend fun clearAuthData() { + // 중복 실행 방지함. 이미 로그아웃 상태면 아무것도 하지 않음 + if (authPreference.userId == null && !authPreference.isLoggedIn) return - //유저 비밀번호 재설정 - override suspend fun requestTempPassword(email: String): Boolean { - return try { - Log.d("UserRepository", "[임시PW 요청] email=$email") // ✅ 호출 시작 로그 - - val res = userApi.requestTempPassword(email) // ← @Query 호출 - - // ✅ 응답 로그 (성공 여부와 메시지 확인) - Log.d( - "UserRepository", - "[임시PW 응답] isSuccess=${res.isSuccess}, code=${res.code}, message=${res.message}, result=${res.result}" - ) - - res.isSuccess == true - } catch (e: HttpException) { - Log.e( - "UserRepository", - "[임시PW API 오류] code=${e.code()} msg=${e.message()}", - e - ) - false - } catch (e: Exception) { - Log.e("UserRepository", "[임시PW 호출 실패] email=$email, error=${e.localizedMessage}", e) - false - } + authPreference.clear() + sessionStore.clear() + Log.d(TAG, "모든 로컬 세션 데이터 삭제 완료") } - -} \ No newline at end of file + companion object { + private const val TAG = "UserRepository" + } +} diff --git a/data/src/main/java/com/example/data/preference/AuthPreference.kt b/data/src/main/java/com/example/data/preference/AuthPreference.kt index 29b37c6e..f575dcf1 100644 --- a/data/src/main/java/com/example/data/preference/AuthPreference.kt +++ b/data/src/main/java/com/example/data/preference/AuthPreference.kt @@ -1,8 +1,30 @@ package com.example.data.preference +//엑세스 토큰 직접 Retrofit 인터셉터가 아닌, 함수 단위로 현재 사용중임. +/* +* 로그인 /api/users/login은 최초 로그인 전용으로, 이메일 로그인에서 사용함. +* 여기서 엑세스 토큰 + 리프레쉬 토큰 응답을 함. +* 이를 AuthPreference에 저장함. +* +* */ + + interface AuthPreference { - var accessToken: String? - var refreshToken: String? - var userId: Long? -} \ No newline at end of file + val isLoggedIn : Boolean + get() = !refreshToken.isNullOrBlank() //로그인 상태 확인 + var accessToken: String? // 모든 인증 api 요청에 사용함. + var refreshToken: String? // 자동로그인/ 엑세스 토큰 재발급의 기준임. 엑세스 토큰은 기간이 짧기에 + var userId: Long? // 사용자 확인용. + + + fun clear() //모든 인증 정보 삭제(로그아웃, 회원탈퇴) + + fun saveTokens( + accessToken: String, + refreshToken: String, + userId: Long + ) + +} + diff --git a/design/src/main/java/com/example/design/SearchTopSheetHost.kt b/design/src/main/java/com/example/design/SearchTopSheetHost.kt index e1e3c780..dc8ee1b1 100644 --- a/design/src/main/java/com/example/design/SearchTopSheetHost.kt +++ b/design/src/main/java/com/example/design/SearchTopSheetHost.kt @@ -50,7 +50,7 @@ fun SearchTopSheetHost( onQueryDelete = onQueryDelete, onQueryClear = onQueryClear, fastSearchItems = filtered, - recentQuerys = recent, + recentQueries = recent, onLinkClick = {} ) } diff --git a/design/src/main/java/com/example/design/modifier/GradientTint.kt b/design/src/main/java/com/example/design/modifier/GradientTint.kt index e2dfe07d..8d1a046f 100644 --- a/design/src/main/java/com/example/design/modifier/GradientTint.kt +++ b/design/src/main/java/com/example/design/modifier/GradientTint.kt @@ -6,10 +6,17 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.graphicsLayer -/* -* 그라데이션 색상을 적용하는 Modifier 확장 함수 -* 기존의 이미지 위에 블렌드 모드에 따라 색을 덮는 방식 -* */ +/** + * Modifier에 그라데이션 효과를 적용하는 확장 함수입니다. + * + * 이 함수는 [graphicsLayer]와 [drawWithCache]를 사용하여 콘텐츠 위에 지정된 [brush]를 덧그립니다. + * 기본적으로 [BlendMode.SrcIn]을 사용하여 이미지나 아이콘의 형태에 맞춰 그라데이션 색상을 입히는 데 유용합니다. + * + * @param brush 콘텐츠에 적용할 그라데이션 [Brush]. + * @param blendMode 그라데이션을 콘텐츠와 결합할 때 사용할 [BlendMode]. 기본값은 [BlendMode.SrcIn]입니다. + * + * @return 그라데이션 틴트가 적용된 [Modifier]. + */ fun Modifier.gradientTint( /* * brush: 그라데이션 색상을 적용할 브러시 diff --git a/design/src/main/java/com/example/design/modifier/NoRippleClickable.kt b/design/src/main/java/com/example/design/modifier/NoRippleClickable.kt index a093b2aa..5a16a52a 100644 --- a/design/src/main/java/com/example/design/modifier/NoRippleClickable.kt +++ b/design/src/main/java/com/example/design/modifier/NoRippleClickable.kt @@ -5,12 +5,35 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.semantics.Role -// 시각적 효과가 없는 클릭을 적용하는 Modifier의 확장 함수 -fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed { +/** + * 시각적 효과(Ripple)가 없는 클릭 이벤트를 적용하는 [Modifier]의 확장 함수. + * + * @param enabled 클릭 가능 여부를 설정합니다. false일 경우 클릭 이벤트가 발생하지 않습니다. + * @param onClickLabel 접근성 서비스를 위한 클릭 작업에 대한 설명 레이블입니다. + * @param role 요소의 역할(예: Button, RadioButton 등)을 정의하여 접근성 서비스에 정보를 제공합니다. + * @param onClick 요소가 클릭되었을 때 실행될 콜백 함수입니다. + * + * @return 시각적 효과가 없는 클릭 가능한 [Modifier]. + */ +fun Modifier.noRippleClickable( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit +): Modifier = composed { clickable( - indication = null, + /* + * interactionSource와 indication의 값을 고정하여 리플 효과를 제거합니다. + * remember { MutableInteractionSource() }를 통해 상호작용 상태를 관리하는 객체를 생성하고, + * indication에 null을 전달하여 클릭 시 시각적 효과가 발생하지 않도록 설정합니다. + */ interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, onClick = onClick ) } diff --git a/design/src/main/java/com/example/design/theme/font/Taebaek.kt b/design/src/main/java/com/example/design/theme/font/Taebaek.kt new file mode 100644 index 00000000..8c48b0d1 --- /dev/null +++ b/design/src/main/java/com/example/design/theme/font/Taebaek.kt @@ -0,0 +1,13 @@ +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 Taebaek : ThemeFontScheme( + font = FontFamily( + Font(R.font.taebaek_font, FontWeight.Normal, FontStyle.Normal) + ) +) \ No newline at end of file diff --git a/design/src/main/java/com/example/design/top/bar/TopBar.kt b/design/src/main/java/com/example/design/top/bar/TopBar.kt new file mode 100644 index 00000000..9d672774 --- /dev/null +++ b/design/src/main/java/com/example/design/top/bar/TopBar.kt @@ -0,0 +1,207 @@ +package com.example.design.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 // 검색바 포함 //기존 파일은 206f + +private val DEFAULT_BACKGROUND = Color.White // 기본 배경 흰색 + +@Composable +fun TopBar( + 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 + + // 로고 + 알림만 있을 때, 탑 바 높이를 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() { + TopBar() +} + +@Preview(showBackground = true, name = "로고 + 알림만") +@Composable +fun PreviewCurationTopBarSimple() { + TopBar(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) + ) { + TopBar( + 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/design/src/main/java/com/example/design/top/search/SearchBarTopSheet.kt b/design/src/main/java/com/example/design/top/search/SearchBarTopSheet.kt index 7c662647..0e06fad9 100644 --- a/design/src/main/java/com/example/design/top/search/SearchBarTopSheet.kt +++ b/design/src/main/java/com/example/design/top/search/SearchBarTopSheet.kt @@ -24,9 +24,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -42,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle @@ -54,19 +52,19 @@ 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.R 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.theme.domain.domainLogoPainterOrNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import androidx.compose.ui.platform.LocalUriHandler -import com.example.design.R data class FastSearchItem( val id: Long, @@ -74,20 +72,23 @@ data class FastSearchItem( val url: String, ) -// 빠른 링크 검색 탑 시트 +/** + * 상단에서 내려오는 형태의 빠른 링크 검색 탑 시트 컴포넌트입니다. + * 사용자가 검색어를 입력하여 링크를 빠르게 찾거나, 최근 검색 기록을 관리할 수 있는 기능을 제공합니다. + * + * @param visible 탑 시트의 표시 여부. true일 때 상단에서 아래로 애니메이션과 함께 나타납니다. + * @param onDismiss 탑 시트를 닫아야 할 때 호출되는 콜백 (배경 클릭, 뒤로가기 버튼 등). + * @param onQueryChange 검색어가 변경될 때 호출되는 콜백. 2자 이상의 입력에 대해 데바운스(350ms) 처리 후 실행됩니다. + * @param onQuerySave 현재 검색어를 최근 검색 기록에 저장하고자 할 때(예: 키보드 완료 버튼 클릭) 호출되는 콜백. + * @param onQueryDelete 최근 검색 기록에서 특정 검색어를 삭제할 때 호출되는 콜백. + * @param onQueryClear 최근 검색 기록의 모든 항목을 삭제할 때 호출되는 콜백. + * @param onLinkClick 검색 결과 중 특정 링크 아이템을 클릭했을 때 호출되는 콜백. 선택된 아이템의 고유 ID를 전달합니다. + * @param fastSearchItems 검색어에 따라 필터링되어 화면에 표시될 빠른 링크 검색 결과 리스트. + * @param recentQueries 사용자에게 보여줄 최근 검색 기록 문자열 리스트. + */ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun SearchBarTopSheet( - /* - * visible: 탑 시트가 보여지는지 여부 - * onDismiss: 탑 시트를 닫을 때 호출되는 콜백 - * onQueryChange: 검색어가 변경될 때 호출되는 콜백 - * onQuerySave: 검색어를 저장할 때 호출되는 콜백 - * onQueryDelete: 검색어를 삭제할 때 호출되는 콜백 - * onQueryClear: 모든 검색어를 삭제할 때 호출되는 콜백 - * fastSearchItems: 빠른 링크 검색 아이템 리스트 - * recentQuerys: 최근 검색 기록 리스트 - * */ visible: Boolean, onDismiss: () -> Unit, onQueryChange: (String) -> Unit, @@ -96,68 +97,104 @@ fun SearchBarTopSheet( onQueryClear: () -> Unit, onLinkClick: (Long) -> Unit, fastSearchItems: List = emptyList(), - recentQuerys: List = emptyList(), + recentQueries: List = emptyList(), ) { - // 링큐 색상 테마 + // 테마 및 리소스 val colors = LocalColorTheme.current - - // 링큐 폰트(paperlogy) val paperlogyFont = LocalFontTheme.current.font - - // 키보드 컨트롤러 인스턴스 val keyboardController = LocalSoftwareKeyboardController.current - // 입력 텍스트 + // 상태 관리 var text by remember { mutableStateOf("") } - - // 수정 상태 + // 기본적으로 수정 모드는 꺼져있음 var isEditMode by remember { mutableStateOf(false) } - // 검색창 입장 시 초기화 - LaunchedEffect(Unit) { + /** + * 공통 초기화 + 닫기 처리 + * (뒤로가기 / 딤 클릭 / 닫기 버튼에서 중복 제거) + */ + fun resetAndDismiss() { text = "" isEditMode = false + keyboardController?.hide() + onDismiss() } - // 입력 변화 디바운스 수집 (2자 이상 + 350ms) - // - mapLatest: 새 입력이 오면 이전 요청(코루틴 Job) 자동 취소 → 레이스 방지 + // 닫힐 때 상태 초기화 로직 (visible이 false가 될 때) + LaunchedEffect(visible) { + if (!visible) { + resetAndDismiss() + } + } + +/* LaunchedEffect(Unit) { snapshotFlow { text } .map { it.trim() } .filter { it.length >= 2 } // 2자 이상일 때만 - .debounce(350) // 300~400ms 권장, 여기선 350ms + .debounce(350) // 350ms 주기 탐색 .distinctUntilChanged() - .mapLatest { q -> - onQueryChange(q) // 최신 입력으로만 호출됨 - } - .collect { /* no-op */ } + .mapLatest(onQueryChange) + .collect { *//* no-op *//* } + }*/ + + /** + * 검색어 변경 디바운스 처리 + * + * - trim 적용 + * - 2자 이상만 검색 + * - 350ms 디바운스 + * - 동일 값 중복 호출 방지 + */ + LaunchedEffect(text) { + snapshotFlow { text } + .map { it.trim() } + .filter { it.length >= 2 } + .debounce(350) + .distinctUntilChanged() + .collectLatest(onQueryChange) } - - // 최근 검색 기록 아이템 + /** + * 최근 검색어 목록의 개별 아이템을 표시하는 컴포저블입니다. + * + * 검색어를 칩(Chip) 형태로 표시하며, [isEditMode] 상태에 따라 두 가지 동작을 수행합니다: + * 1. 일반 모드: 텍스트 클릭 시 해당 검색어를 입력창에 반영합니다. + * 2. 수정 모드: 검색어 옆에 삭제 버튼을 표시하여 개별 기록을 삭제할 수 있게 합니다. + * + * @param query 표시할 최근 검색어 문자열입니다. + */ @Composable - fun RecentQueryItem(recentText: String){ + fun RecentQueryChip(query: String){ + + // 가로 캡슐 모양의 아이템 Row ( modifier = Modifier + + // 검색어 길이에 따라 크기가 가변적 .wrapContentSize() + .background( + // 양옆을 둥글게 shape = RoundedCornerShape(size = 10.dp), + + // Gray 100 배경색 color = colors.gray[100] - ), + ) + .padding(horizontal = 15.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { // 태그 텍스트 Text( modifier = Modifier - .padding(horizontal = 15.dp, vertical = 10.dp) .noRippleClickable { if (!isEditMode) { - text = recentText + text = query keyboardController?.hide() } }, - text = recentText, + text = query, fontSize = 14.sp, lineHeight = 20.sp, fontFamily = paperlogyFont, @@ -171,55 +208,71 @@ fun SearchBarTopSheet( Icon( modifier = Modifier .noRippleClickable{ - onQueryDelete(recentText) + onQueryDelete(query) }, - imageVector = Icons.Default.Close, - tint = colors.gray[800], - contentDescription = null, + painter = painterResource(id = R.drawable.ic_recent_search_x), + tint = colors.gray[500], + contentDescription = "수정 상태에서만 보이는 삭제 버튼", ) } } } - // 빠른 링크 제목 부분 강조 텍스트 + /** + * 검색어와 일치하는 텍스트 부분을 굵게(Bold) 강조하여 표시하는 컴포저블 함수입니다. + * + * 전체 문자열([fullText]) 내에서 사용자가 입력한 검색어([searchTerm])를 찾아 + * 해당 부분에만 [FontWeight.Bold] 스타일을 적용한 [AnnotatedString]을 생성하여 출력합니다. + * 정규식을 사용하여 대소문자 구분 없이 일치하는 모든 구간을 처리하며, + * 텍스트가 길어질 경우 끝부분을 생략(...) 처리합니다. + * + * @param fullText 표시할 전체 원본 문자열입니다. + * @param searchTerm 강조 스타일을 적용할 검색어 문자열입니다. + */ @Composable fun HighlightedText( - suggestion: String, - query: String + fullText: String, + searchTerm: String ) { - val annotatedString = buildAnnotatedString { - if (query.isEmpty()) { - append(suggestion) + + // 검색어가 검색 결과에 강조된 문자열 + val highlightedResult = buildAnnotatedString { + + // 검색어가 없다면, 강조 없이 반환 + if (searchTerm.isEmpty()) { + append(fullText) return@buildAnnotatedString } - // query를 모두 찾아내는 정규식 - val regex = Regex("(?i)${Regex.escape(query)}") + // 검색어를 모두 찾아내는 정규식 + val regex = Regex("(?i)${Regex.escape(searchTerm)}") var lastIndex = 0 - regex.findAll(suggestion).forEach { matchResult -> - val start = matchResult.range.first - val end = matchResult.range.last + 1 + // 찾아낸 검색어들에 하이라이트 처리 + regex.findAll(fullText).forEach { matchResult -> + val matchStart = matchResult.range.first + val matchEnd = matchResult.range.last + 1 // 검색어 앞부분 그대로 추가 - append(suggestion.substring(lastIndex, start)) + append(fullText.substring(lastIndex, matchStart)) // 검색어 부분 Bold 처리 withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(suggestion.substring(start, end)) + append(fullText.substring(matchStart, matchEnd)) } - lastIndex = end + lastIndex = matchEnd } // 마지막 남은 부분 추가 - if (lastIndex < suggestion.length) { - append(suggestion.substring(lastIndex)) + if (lastIndex < fullText.length) { + append(fullText.substring(lastIndex)) } } + // 강조된 텍스트 Text( - text = annotatedString, + text = highlightedResult, fontSize = 15.sp, lineHeight = 22.sp, fontFamily = paperlogyFont, @@ -230,19 +283,29 @@ fun SearchBarTopSheet( ) } - // 빠른 링크 검색 아이템 + /** + * 빠른 링크 검색 결과를 표시하는 개별 아이템 컴포저블입니다. + * + * 해당 아이템은 링크의 도메인 로고 이미지와 함께 제목을 표시하며, + * 현재 검색어와 일치하는 제목의 텍스트 부분을 강조(Bold)하여 보여줍니다. + * + * @param fastSearchItem 표시할 검색 결과 아이템 데이터 ([FastSearchItem]) + */ @Composable - fun FastSearchItem(fastSearchItem: FastSearchItem){ + fun FastSearchItemRow(fastSearchItem: FastSearchItem){ - val domainImg = domainLogoPainterOrNull(fastSearchItem.url) + // 도메인 로고 + val logoPainter = domainLogoPainterOrNull(fastSearchItem.url) - val uri = LocalUriHandler.current + // uri 핸들러 + val uriHandler = LocalUriHandler.current // 링크 내용 Row( modifier = Modifier .noRippleClickable{ runCatching { onLinkClick(fastSearchItem.id) } + .onFailure { /* 링크 클릭 에러 시 처리 */ } }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically @@ -258,19 +321,20 @@ fun SearchBarTopSheet( shape = CircleShape ) .background(colors.white), - painter = domainImg?:painterResource(R.drawable.logo_whiteback), + painter = logoPainter?:painterResource(R.drawable.logo_whiteback), contentDescription = null ) // 링크 제목 HighlightedText( - suggestion = fastSearchItem.title, - query = text + fullText = fastSearchItem.title, + searchTerm = text ) } } - // 바탕 Box + /* ===================== UI ===================== */ + Box( modifier = Modifier.fillMaxSize() ) { @@ -281,8 +345,13 @@ fun SearchBarTopSheet( .matchParentSize() // 뒷 배경 딤 효과 .background(Color.Black.copy(alpha = 0.3f)) - // 클릭 시, 닫힘 - .noRippleClickable { onDismiss() } + + // 뒷 배경 클릭 시 닫힘 및 검색어, 수정 모드 초기화 + .noRippleClickable { + text = "" + isEditMode = false + onDismiss() + } ) } @@ -326,8 +395,8 @@ fun SearchBarTopSheet( // 위에서 46dp 떨어지게 위치 .padding(top = 46.dp), - // 21dp 간격 가로 배치 - horizontalArrangement = Arrangement.spacedBy(16.dp), + // 20dp 간격 가로 배치 + horizontalArrangement = Arrangement.spacedBy(20.dp), // 세로 중앙 정렬 verticalAlignment = Alignment.CenterVertically @@ -338,14 +407,18 @@ fun SearchBarTopSheet( // 아이콘 크기 .height(40.dp) - // 클릭 시 닫힘 - .noRippleClickable { onDismiss() }, + // 뒤로 가기 클릭 시 닫힘 및 검색어, 수정 모드 초기화 + .noRippleClickable { + text = "" + isEditMode = false + onDismiss() + }, // 아이콘 색상 Gray600 tint = colors.gray[600], // compose 제공 왼쪽 화살표 이미지 - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + painter = painterResource(id = R.drawable.ic_back), contentDescription = null ) @@ -375,6 +448,8 @@ fun SearchBarTopSheet( painter = painterResource(id = R.drawable.ic_logo_white), contentDescription = "링큐 로고" ) + + // 검색어 입력 부분 BasicTextField( value = text, onValueChange = { text = it }, @@ -419,14 +494,17 @@ fun SearchBarTopSheet( } ) - Image( - modifier = Modifier - .padding(end = 18.dp) - .size(18.dp) - .noRippleClickable { text = "" }, - painter = painterResource(R.drawable.ic_text_clear), - contentDescription = null - ) + // 검색어 삭제 버튼 + if(text.isNotEmpty()){ + Image( + modifier = Modifier + .padding(end = 18.dp) + .size(18.dp) + .noRippleClickable { text = "" }, + painter = painterResource(R.drawable.ic_text_clear), + contentDescription = null + ) + } } } } @@ -440,78 +518,92 @@ fun SearchBarTopSheet( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + + // 최근 검색 or 검색 결과 Text( text = if (text.isEmpty()) "최근 검색" else "검색 결과", fontSize = 14.sp, lineHeight = 20.sp, fontFamily = paperlogyFont, - fontWeight = FontWeight.Normal, + fontWeight = FontWeight.Bold, color = colors.black ) + // 입력된 검색어가 없을 때, if(text.isEmpty()){ - when (isEditMode) { - true -> { - Row( - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.noRippleClickable { - onQueryClear() - }, - text = "모두 지우기", - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = paperlogyFont, - fontWeight = FontWeight.Normal, - color = colors.gray[400] - ) - Text( - modifier = Modifier.noRippleClickable { - isEditMode = false - }, - text = "완료", - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = paperlogyFont, - fontWeight = FontWeight.Normal, - color = colors.black - ) - } - } - false -> { + // 수정 상태이면, + if (isEditMode) { + + // 모두 지우기 버튼, 완료 버튼 + Row( + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + // 모두 지우기 버튼 + Text( + modifier = Modifier.noRippleClickable (onClick = onQueryClear), + text = "모두 지우기", + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = paperlogyFont, + fontWeight = FontWeight.Normal, + color = colors.gray[400] + ) + + // 완료 버튼 Text( + + // 클릭 시, 일반 상태로 변경 modifier = Modifier.noRippleClickable { - isEditMode = true + isEditMode = false }, - text = "수정", + text = "완료", fontSize = 14.sp, lineHeight = 20.sp, fontFamily = paperlogyFont, fontWeight = FontWeight.Normal, - color = colors.gray[400] + color = colors.black ) } + } else { // 일반 상태일 때, + + // 수정 버튼 + Text( + + // 클릭 시, 수정 상태로 변경 + modifier = Modifier.noRippleClickable { + isEditMode = true + }, + text = "수정", + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = paperlogyFont, + fontWeight = FontWeight.Normal, + color = colors.gray[400] + ) } } } // 입력 여부에 따라 최근 검색 기록 or 검색 결과 표시 - when (text.isEmpty()) { - true -> { - LazyRow( - modifier = Modifier.padding(top = 129.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - items(recentQuerys) { - RecentQueryItem(it) - } + if (text.isBlank()) { + + // 최근 검색 기록 + LazyRow( + modifier = Modifier.padding(top = 129.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(recentQueries) { + RecentQueryChip(it) } } + } else { - false -> { + // 검색 결과가 있으면 검색 결과 표시, + // 검색 결과가 없으면 "찾으시는 검색어 결과가 없어요!" 표시 + if(fastSearchItems.isNotEmpty()) { LazyColumn( modifier = Modifier .fillMaxWidth() @@ -520,9 +612,34 @@ fun SearchBarTopSheet( verticalArrangement = Arrangement.spacedBy(15.dp) ) { items(fastSearchItems) { - FastSearchItem(it) + FastSearchItemRow(it) } } + } else { + + // "찾으시는 검색어 결과가 없어요!" 공간 + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 22.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ){ + // "찾으시는 검색어 결과가 없어요!" 아이콘 + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_search_bar_caution), + contentDescription = "\"찾으시는 검색어 결과가 없어요!\" 아이콘", + tint = Basic.negative + ) + + // "찾으시는 검색어 결과가 없어요!" 텍스트 + Text( + text = "찾으시는 검색어 결과가 없어요!", + fontSize = 13.sp, + color = Basic.negative + ) + } } } } @@ -543,6 +660,7 @@ private fun SearchBarTopSheetTest(){ onQuerySave = {}, onQueryDelete = {}, onQueryClear = {}, - onLinkClick = {} + onLinkClick = {}, + recentQueries = listOf("최근 검색 1", "최근 검색 2", "최근 검색 3") ) } \ No newline at end of file diff --git a/design/src/main/res/drawable/ic_recent_search_x.xml b/design/src/main/res/drawable/ic_recent_search_x.xml new file mode 100644 index 00000000..ee5d342d --- /dev/null +++ b/design/src/main/res/drawable/ic_recent_search_x.xml @@ -0,0 +1,9 @@ + + + diff --git a/design/src/main/res/drawable/ic_search_bar_caution.xml b/design/src/main/res/drawable/ic_search_bar_caution.xml new file mode 100644 index 00000000..a9dbcb98 --- /dev/null +++ b/design/src/main/res/drawable/ic_search_bar_caution.xml @@ -0,0 +1,14 @@ + + + + diff --git a/design/src/main/res/font/taebaek_font.otf b/design/src/main/res/font/taebaek_font.otf new file mode 100644 index 00000000..ba854d20 Binary files /dev/null and b/design/src/main/res/font/taebaek_font.otf differ 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 6acc3b6e..e81c015b 100644 --- a/feature/curation/src/main/java/com/example/curation/CurationApp.kt +++ b/feature/curation/src/main/java/com/example/curation/CurationApp.kt @@ -1,11 +1,73 @@ -//package com.example.linku_android.curation -// -//import androidx.compose.runtime.Composable -//import com.example.curation.CurationViewModel -//import com.example.curation.ui.CurationScreen -// -// -//@Composable -//fun CurationApp(viewModel: CurationViewModel) { -// CurationScreen() -//} \ No newline at end of file +package com.example.linku_android.curation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +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.CurationScreen + +/** + * 큐레이션 기능의 내비게이션 그래프 정의 + * 이건 아예 전면 변경이라 일단 MainAPP에서 분리만 시켰습니다. + */ +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/ui/CurationScreen.kt b/feature/curation/src/main/java/com/example/curation/ui/CurationScreen.kt index 8fb84041..1626fb18 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 @@ -57,11 +57,11 @@ 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.curation.ui.top_bar.CurationTopBar 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 { @@ -114,7 +114,7 @@ fun CurationScreen( Scaffold( topBar = { - CurationTopBar( + TopBar( onClickSearch = { viewModel.updateSearchTopSheetVisible(true) } ) }, @@ -306,7 +306,7 @@ fun CurationScreen( onQueryDelete = { viewModel.removeRecentQuery(it) }, onQueryClear = { viewModel.clearRecentQuery() }, fastSearchItems = viewModel.fastSearchItems.collectAsState().value, - recentQuerys = viewModel.recentQueryList.collectAsState().value.map{it.text} + recentQueries = viewModel.recentQueryList.collectAsState().value.map{it.text} ) } @@ -455,7 +455,12 @@ fun PreviewCurationScreenExact() { .background(LocalColorTheme.current.white), contentPadding = PaddingValues(bottom = 32.dp) ) { - item { CurationTopBar() } + item { + TopBar( + showSearchBar = true, + onClickSearch = {}, // Preview라서 빈 람다 + onClickAlarm = {} + ) } item { Spacer(Modifier.height(19.dp)) } 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 index 63d015ae..469bfddf 100644 --- 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 @@ -1,107 +1,206 @@ - -package com.example.curation.ui.top_bar - -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.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.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.Paperlogy -import com.example.curation.ui.util.rememberScaleFactor -import com.example.design.theme.LocalColorTheme -import com.example.design.theme.color.Basic -import com.example.design.R as Res - -@Composable -fun CurationTopBar( - onClickSearch: () -> Unit = {} -) { - val scaleFactor = rememberScaleFactor() - val gap = (15 * scaleFactor).dp - - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)) - .background(LocalColorTheme.current.white) - ) { - - /** ─── 로고 ─── */ - Image( - painter = painterResource(id = Res.drawable.ic_linkukor), - contentDescription = "링큐 로고", - contentScale = ContentScale.Fit, - modifier = Modifier - .align(Alignment.TopStart) - .padding(start = 35.dp, top = 44.dp) - .size(width = 48.dp, height = 24.dp) - ) - - /** ─── 알림 아이콘 ─── */ - Icon( - painter = painterResource(id = Res.drawable.ic_alarm), - contentDescription = "알림", - tint = LocalColorTheme.current.gray[300], - modifier = Modifier - .align(Alignment.TopEnd) - .padding(end = 29.8.dp, top = 44.dp) - .size(width = 22.dp, height = 27.dp) - ) - - /** ─── 빠른 링크 검색바 ─── */ - Box( - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp, - top = 44.dp + 24.dp + gap - - ) - .align(Alignment.TopCenter) - .height(48.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(brush = Basic.maincolor) - .clickable { onClickSearch() } - .padding(horizontal = 18.dp), - contentAlignment = Alignment.CenterStart - ) { - Icon( - painter = painterResource(id = Res.drawable.ic_logo_white), - contentDescription = null, - tint = LocalColorTheme.current.white, - modifier = Modifier - .width(23.97571.dp) - .height(17.dp) - ) - - Text( - text = "빠른 링크 검색", - color = LocalColorTheme.current.white, - fontFamily = Paperlogy, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 32.dp) - ) - } - } -} - - -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) -@Composable -fun PreviewCurationTopBar() { - CurationTopBar()} +//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/file/build.gradle.kts b/feature/file/build.gradle.kts index 9328fb4e..7355fce8 100644 --- a/feature/file/build.gradle.kts +++ b/feature/file/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.compose.foundation.layout) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/feature/file/src/main/java/com/example/file/FileScreen.kt b/feature/file/src/main/java/com/example/file/FileScreen.kt index 8c128977..117ab9de 100644 --- a/feature/file/src/main/java/com/example/file/FileScreen.kt +++ b/feature/file/src/main/java/com/example/file/FileScreen.kt @@ -284,7 +284,7 @@ fun FileScreen( onQueryDelete = { fileViewModel.removeRecentQuery(it) }, onQueryClear = { fileViewModel.clearRecentQuery() }, fastSearchItems = fileViewModel.fastSearchItems.collectAsState().value, - recentQuerys = fileViewModel.recentQueryList.collectAsState().value.map{it.text} + recentQueries = fileViewModel.recentQueryList.collectAsState().value.map{it.text} ) } diff --git a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/BottomFolderEditBottomSheet.kt b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/BottomFolderEditBottomSheet.kt index b54b89a2..82e63c04 100644 --- a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/BottomFolderEditBottomSheet.kt +++ b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/BottomFolderEditBottomSheet.kt @@ -1,9 +1,11 @@ package com.example.file.ui.bottom.sheet +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import com.example.file.FileViewModel import com.example.file.viewmodel.folder.state.FolderStateViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BottomFolderEditBottomSheet( onTextDeliver: (String) -> Unit, @@ -14,6 +16,9 @@ fun BottomFolderEditBottomSheet( body = "변경할 폴더명을 입력해주세요!", placeholderText = folderStateViewModel.readyToUpdateBottomFolder?.folderName?:"에러", visible = folderStateViewModel.bottomFolderEditBottomSheetVisible, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), onTextDeliver = { onTextDeliver(it) }, onDismiss = { folderStateViewModel.updateBottomFolderEditBottomSheetVisible(false) } ) diff --git a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/NewBottomFolderBottomSheet.kt b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/NewBottomFolderBottomSheet.kt index c82cdda9..aa9d16b1 100644 --- a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/NewBottomFolderBottomSheet.kt +++ b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/NewBottomFolderBottomSheet.kt @@ -1,8 +1,10 @@ package com.example.file.ui.bottom.sheet +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import com.example.file.viewmodel.folder.state.FolderStateViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewBottomFolderBottomSheet( onTextDeliver: (String) -> Unit, diff --git a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TextFieldFileBottomSheet.kt b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TextFieldFileBottomSheet.kt index b8349c22..81c74d28 100644 --- a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TextFieldFileBottomSheet.kt +++ b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TextFieldFileBottomSheet.kt @@ -26,7 +26,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.SheetState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -70,6 +72,7 @@ fun TextFieldFileBottomSheet( placeholderText: String, isEditable: Boolean = false, visible: Boolean, + sheetState: SheetState = rememberModalBottomSheetState(), onTextDeliver: (String) -> Unit = {}, onColorIdDeliver: (Int) -> Unit = {}, onDismiss: () -> Unit, @@ -87,6 +90,7 @@ fun TextFieldFileBottomSheet( FileBottomSheet( modifier = modifier, + sheetState = sheetState, title = title, body = body, buttonText = "저장", @@ -203,13 +207,14 @@ fun TextFieldFileBottomSheet( } AnimatedVisibility( modifier = Modifier - .padding(top = 14.dp) .padding(horizontal = 26.5.dp), visible = expanded, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { VerticalGrid( + modifier = Modifier + .padding(top = 14.dp), columns = SimpleGridCells.Fixed(8), horizontalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.spacedBy(7.5.dp) @@ -239,6 +244,7 @@ fun TextFieldFileBottomSheet( } } +@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true, heightDp = 2000) @Composable private fun TextFieldFileBottomSheetTest(){ diff --git a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TopFolderEditBottomSheet.kt b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TopFolderEditBottomSheet.kt index ae6b6df5..1168cf50 100644 --- a/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TopFolderEditBottomSheet.kt +++ b/feature/file/src/main/java/com/example/file/ui/bottom/sheet/TopFolderEditBottomSheet.kt @@ -1,10 +1,13 @@ package com.example.file.ui.bottom.sheet +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import com.example.file.FileViewModel import com.example.design.theme.color.CategoryColorStyle import com.example.file.viewmodel.folder.state.FolderStateViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TopFolderEditBottomSheet( folderStateViewModel: FolderStateViewModel, @@ -16,6 +19,9 @@ fun TopFolderEditBottomSheet( placeholderText = "카테고리명은 현재 변경 불가능합니다.", isEditable = true, visible = folderStateViewModel.topFolderEditBottomSheetVisible, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), onColorIdDeliver = { colorId -> fileViewModel.updateCategoryColor( categoryName = folderStateViewModel.readyToUpdateTopFolder!!.folderName, diff --git a/feature/file/src/main/java/com/example/file/ui/content/BottomFolderGrid.kt b/feature/file/src/main/java/com/example/file/ui/content/BottomFolderGrid.kt index 20d51653..6481aa89 100644 --- a/feature/file/src/main/java/com/example/file/ui/content/BottomFolderGrid.kt +++ b/feature/file/src/main/java/com/example/file/ui/content/BottomFolderGrid.kt @@ -171,44 +171,45 @@ fun BottomFolderGrid( } - // "분류되지 않은 링크" 텍스트 - Text( - text = "분류되지 않은 링크", - fontSize = 20.sp, - lineHeight = 30.sp, - fontFamily = DefaultFont, - fontWeight = FontWeight(700), - color = Black, - modifier = Modifier.padding(top = 40.dp, bottom = 20.dp) // 위아래 간격 추가 - ) - - - // Link Grid - VerticalGrid( - modifier = Modifier - .fillMaxWidth(), - columns = SimpleGridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(9.dp), - verticalArrangement = Arrangement.spacedBy(18.51.dp) - ) { - // items 람다 안에 file을 넘겨줘야 LinkItemLayout에서 사용할 수 있어! - for((i, link) in linkList.withIndex()){ - Box( - modifier = Modifier - .fillMaxWidth(), - contentAlignment = if(i%2==0) Alignment.TopStart else Alignment.TopEnd - ) { - LinkItemLayout( - link = link, - onClick = { - fileViewModel.onLinkClick?.invoke(link.linkuId) - }, - onLongClick = { - selectedLinkId = link.linkuId - - deleteModalWindowVisible = true - } - ) + if(linkList.isNotEmpty()){// "분류되지 않은 링크" 텍스트 + Text( + text = "분류되지 않은 링크", + fontSize = 20.sp, + lineHeight = 30.sp, + fontFamily = DefaultFont, + fontWeight = FontWeight(700), + color = Black, + modifier = Modifier.padding(top = 40.dp, bottom = 20.dp) // 위아래 간격 추가 + ) + + + // Link Grid + VerticalGrid( + modifier = Modifier + .fillMaxWidth(), + columns = SimpleGridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalArrangement = Arrangement.spacedBy(18.51.dp) + ) { + // items 람다 안에 file을 넘겨줘야 LinkItemLayout에서 사용할 수 있어! + for ((i, link) in linkList.withIndex()) { + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = if (i % 2 == 0) Alignment.TopStart else Alignment.TopEnd + ) { + LinkItemLayout( + link = link, + onClick = { + fileViewModel.onLinkClick?.invoke(link.linkuId) + }, + onLongClick = { + selectedLinkId = link.linkuId + + deleteModalWindowVisible = true + } + ) + } } } } diff --git a/feature/file/src/main/java/com/example/file/ui/top/bar/component/TopFolderListMenu.kt b/feature/file/src/main/java/com/example/file/ui/top/bar/component/TopFolderListMenu.kt index c252d7de..20f80594 100644 --- a/feature/file/src/main/java/com/example/file/ui/top/bar/component/TopFolderListMenu.kt +++ b/feature/file/src/main/java/com/example/file/ui/top/bar/component/TopFolderListMenu.kt @@ -1,19 +1,24 @@ package com.example.file.ui.top.bar.component +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem 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.drawWithCache import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.graphicsLayer +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 @@ -22,8 +27,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.design.modifier.gradientTint +import com.example.design.modifier.noRippleClickable +import com.example.design.theme.color.Basic import com.example.file.FileViewModel +import com.example.file.R import com.example.file.ui.theme.Black import com.example.file.ui.theme.DefaultFont import com.example.file.ui.theme.MainColor @@ -31,93 +39,230 @@ import com.example.file.ui.theme.White import com.example.file.viewmodel.folder.state.FolderState import com.example.file.viewmodel.folder.state.FolderStateViewModel +/** + * 폴더 목록의 상단 메뉴를 관리하는 컴포저블 함수입니다. + * [FolderStateViewModel]과 [FileViewModel]을 사용하여 메뉴의 상태를 제어하고, + * 사용자의 선택에 따라 "나의 폴더"와 "공유받은 폴더" 사이의 전환 로직을 수행합니다. + * + * @param folderStateViewModel 메뉴의 확장 상태 및 폴더 유형 상태를 관리하는 뷰모델. + * @param fileViewModel 공유 폴더 목록 조회 등 파일 관련 데이터를 처리하는 뷰모델. + * + * @see TopFolderListMenuLayout + */ @Composable fun TopFolderListMenu( folderStateViewModel: FolderStateViewModel, fileViewModel: FileViewModel ){ - val items = listOf("나의 폴더", "공유받은 폴더") - var selectedText = if (folderStateViewModel.isSharedFolders) "공유받은 폴더" else "나의 폴더" + TopFolderListMenuLayout( + isSharedFolders = folderStateViewModel.isSharedFolders, + topMenuExpanded = folderStateViewModel.topMenuExpanded, + onDismissRequest = { + /* + * 메뉴의 열린 상태를 닫힘으로 수정하는 로직 + * */ + + // topMenuExpanded를 false로 수정 + folderStateViewModel.updateTopMenuExpanded(false) + }, + onSelectMyFolders = { + /* + * 메뉴를 닫음과 동시에 + * 공유받은 폴더들을 보이지 않게 하고 + * 나의 폴더들을 보이게 하는 로직 + * */ + + // isShredFolders를 false로 수정 + folderStateViewModel.updateIsSharedFolders(false) + + // topMenuExpanded를 false로 수정 + folderStateViewModel.updateTopMenuExpanded(false) + + // folderState를 TOP으로 수정 + folderStateViewModel.updateFolderState(FolderState.TOP) + }, + onSelectSharedFolders = { + /* + * 메뉴를 닫음과 동시에 + * 나의 폴더들을 보이지 않게 하고 + * 공유받은 폴더들을 보이게 하는 로직 + */ + + // 공유 폴더를 받아 뷰모델에 저장 + fileViewModel.getSharedFolders() + + // isShredFolders를 true로 수정 + folderStateViewModel.updateIsSharedFolders(true) + + // topMenuExpanded를 false로 수정 + folderStateViewModel.updateTopMenuExpanded(false) + + // folderState를 TOP으로 수정 + folderStateViewModel.updateFolderState(FolderState.TOP) + } + ) +} + +/** + * "나의 폴더"와 "공유받은 폴더" 사이를 전환할 수 있는 드롭다운 메뉴 레이아웃을 표시합니다. + * + * @param modifier 드롭다운 메뉴에 적용할 [Modifier]. + * @param isSharedFolders 현재 선택된 뷰가 "공유받은 폴더"인지 여부. + * @param topMenuExpanded 메뉴의 표시 상태 (true이면 메뉴가 열림). + * @param onDismissRequest 메뉴 바깥 영역을 클릭하거나 닫으려 할 때 호출되는 콜백. + * @param onSelectMyFolders 메뉴에서 "나의 폴더"를 선택했을 때 수행할 동작. + * @param onSelectSharedFolders 메뉴에서 "공유받은 폴더"를 선택했을 때 수행할 동작. + * + * @see TopFolderListMenu + * @see TopFolderListMenuRow + */ +@Composable +private fun TopFolderListMenuLayout( + modifier: Modifier = Modifier, + isSharedFolders: Boolean, + topMenuExpanded: Boolean, + onDismissRequest: () -> Unit, + onSelectMyFolders: () -> Unit, + onSelectSharedFolders: () -> Unit +){ + // 선택된 폴더 + val selectedText = if (isSharedFolders) "공유받은 폴더" else "나의 폴더" + + /* + * 상단 폴더 목록 메뉴 + * + * 구조: + * [ v 나의 폴더 ] + * [ 공유받은 폴더 ] + * */ DropdownMenu( - modifier = Modifier - .width(150.dp), + modifier = modifier + .width(180.dp) + .padding(vertical = 15.dp), shape = RoundedCornerShape(18.dp), + + // offset = DpOffset(0.dp, 10.dp), - expanded = folderStateViewModel.topMenuExpanded, - onDismissRequest = { folderStateViewModel.updateTopMenuExpanded(false) }, - containerColor = White + expanded = topMenuExpanded, + onDismissRequest = onDismissRequest, + containerColor = Basic.white ) { - for ((i, selectedOption) in items.withIndex()){ - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier - .graphicsLayer(alpha = 0.99f) // 강제 레이어 - .drawWithCache { - onDrawWithContent { - drawContent() // 기본 아이콘 먼저 그림 - drawRect( - brush = if (selectedOption == selectedText) MainColor - else Brush.horizontalGradient(listOf(White, White)), - blendMode = BlendMode.SrcAtop // 아이콘 영역만 그라데이션 입힘! - ) - } - } - ) - }, - text = { - Text( - text = buildAnnotatedString { - withStyle( - SpanStyle( - // 폰트 크기 (15sp) - fontSize = 15.sp, - - // 사용할 폰트 (paperlogy 폰트) - fontFamily = DefaultFont, - - // 폰트 굵기 - fontWeight = FontWeight(if (selectedOption == selectedText) 500 else 400), - - // 텍스트 그라데이션 색상(링큐 메인 색상) - brush = if (selectedOption == selectedText) MainColor - else Brush.horizontalGradient(listOf(Black, Black)) - ) - ) { - // 실제 표시할 텍스트 - append(selectedOption) - } - }, - ) - }, - onClick = { - if (selectedOption != selectedText){ - if (i == 0) { - // 나의 폴더 클릭 시 - folderStateViewModel.updateIsSharedFolders(false) - } else { - // 공유 받은 폴더 클릭 시 - fileViewModel.getSharedFolders() - folderStateViewModel.updateIsSharedFolders(true) - } - folderStateViewModel.updateTopMenuExpanded(false) - folderStateViewModel.updateFolderState(FolderState.TOP) - } - } + Column( + verticalArrangement = Arrangement.spacedBy(13.dp, Alignment.CenterVertically), + ) { + TopFolderListMenuRow( + selectedOption = "나의 폴더", + selectedText = selectedText, + onClick = onSelectMyFolders + ) + + TopFolderListMenuRow( + selectedOption = "공유받은 폴더", + selectedText = selectedText, + onClick = onSelectSharedFolders ) } } } -@Preview() +/** + * 상단 폴더 목록 메뉴의 개별 항목을 표시하는 컴포저블 함수입니다. + * 현재 선택된 상태에 따라 아이콘과 텍스트의 강조(색상, 굵기 등) 스타일을 다르게 적용합니다. + * + * @param selectedOption 해당 메뉴 항목이 나타내는 옵션의 명칭 (예: "나의 폴더", "공유받은 폴더"). + * @param selectedText 현재 실제로 선택되어 있는 옵션의 명칭. + * @param onClick 항목이 클릭되었을 때 실행할 콜백 함수. 현재 선택된 옵션과 다를 경우에만 작동합니다. + * + * @see TopFolderListMenuLayout + */ +@Composable +private fun TopFolderListMenuRow( + selectedOption: String, + selectedText: String, + onClick: () -> Unit +){ + /* + * 폴더 목록의 개별 항목 + * + * 구조: [체크 박스 -> 이름] + * */ + + Row( + modifier = Modifier + + // 가로 전체 사용 + .fillMaxWidth() + + // 체크가 안된 항목만 체크 가능 + .noRippleClickable( + enabled = selectedOption != selectedText, + onClick = onClick + ), + + verticalAlignment = Alignment.CenterVertically + ) { + + // 맨 앞 여백 + Spacer(modifier = Modifier.width(21.dp)) + + // 체크 박스 공간 + Icon( + painter = painterResource(R.drawable.ic_top_folders_menu), + contentDescription = null, + modifier = Modifier + .gradientTint( + // 선택된 항목은 그라데이션, + // 아닌 항목은 체크가 안 보이게 흰색. + brush = if (selectedOption == selectedText) MainColor + else Brush.horizontalGradient(listOf(White, White)), + + // 공간 내 로고 부분만 색칠하기 위해 SrcAtop으로 설정 + blendMode = BlendMode.SrcAtop + ) + ) + + // 체크 박스 공간과 항목명 사이 여백 + Spacer(modifier = Modifier.width(8.dp)) + + // 항목명 + Text( + text = buildAnnotatedString { + withStyle( + SpanStyle( + // 폰트 크기 (15sp) + fontSize = 15.sp, + + // 사용할 폰트 (paperlogy 폰트) + fontFamily = DefaultFont, + + // 폰트 굵기 + fontWeight = FontWeight( + weight = if (selectedOption == selectedText) 500 + else 400 + ), + + // 텍스트 그라데이션 색상(링큐 메인 색상) + brush = if (selectedOption == selectedText) MainColor + else Brush.horizontalGradient(listOf(Black, Black)) + ) + ) { + // 항목명 텍스트 + append(selectedOption) + } + }, + ) + } +} + +@Preview(heightDp = 900) @Composable private fun FolderListMenuTest(){ - val folderStateViewModel: FolderStateViewModel = viewModel() - TopFolderListMenu( - folderStateViewModel = folderStateViewModel, - fileViewModel = viewModel() + TopFolderListMenuLayout( + isSharedFolders = true, + topMenuExpanded = true, + onDismissRequest = {}, + onSelectMyFolders = {}, + onSelectSharedFolders = {} ) -} \ No newline at end of file +} diff --git a/feature/file/src/main/res/drawable/ic_top_folders_menu.xml b/feature/file/src/main/res/drawable/ic_top_folders_menu.xml new file mode 100644 index 00000000..8c9d36a9 --- /dev/null +++ b/feature/file/src/main/res/drawable/ic_top_folders_menu.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/feature/home/src/main/java/com/example/home/HomeViewModel.kt b/feature/home/src/main/java/com/example/home/HomeViewModel.kt index dc3ed47f..3cc22b87 100644 --- a/feature/home/src/main/java/com/example/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/example/home/HomeViewModel.kt @@ -44,6 +44,7 @@ class HomeViewModel @Inject constructor( private val recentRepository: RecentSearchRepository, ) : ViewModel() { + // 자돌 로그인 하고 이 함수가 가장 먼저 실행함. // 최초 진입 시 프로필 로드 (유지 가능) init { loadRecentLinks() @@ -79,6 +80,7 @@ class HomeViewModel @Inject constructor( } // 🔧 1) public 으로, 그리고 userId 없으면 그냥 return + // 여기는 토큰 사용이 없음. fun loadUserBasics() { viewModelScope.launch { val userId = authPreference.userId @@ -105,6 +107,18 @@ class HomeViewModel @Inject constructor( loadRecentLinks() } + //로그아웃 시 모든 데이터 비워주는 기능 + fun clearData() { + // 모든 상태값 초기화 + userNameState.value = null + jobIdState.value = null + recentLinksState.value = emptyList() + linkDetailState.value = null + linkCache.clear() // 상세 정보 캐시도 삭제 + _categoryColorMap.value = emptyMap() + categoryLoaded = false + } + // private fun loadUserBasics() { // viewModelScope.launch { @@ -327,6 +341,7 @@ class HomeViewModel @Inject constructor( // } // 최근 조회 링크 로딩 + // 가장 먼저 호출되는 api? 토큰 달고 요청을 함. fun loadRecentLinks() { viewModelScope.launch { runCatching { linkuRepository.getRecentLinks(limit = 10) } 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 bc89c148..8888a4a4 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 @@ -525,7 +525,7 @@ fun HomeScreen( onQueryDelete = { homeViewModel.removeRecentQuery(it) }, onQueryClear = { homeViewModel.clearRecentQuery() }, fastSearchItems = homeViewModel.fastSearchItems.collectAsState().value, - recentQuerys = homeViewModel.recentQueryList.collectAsState().value.map{it.text} + recentQueries = homeViewModel.recentQueryList.collectAsState().value.map{it.text} ) } diff --git a/feature/login/build.gradle.kts b/feature/login/build.gradle.kts index 28fa77b0..ce1c0749 100644 --- a/feature/login/build.gradle.kts +++ b/feature/login/build.gradle.kts @@ -77,6 +77,13 @@ dependencies { implementation(project(":data")) implementation(project(":design")) + + // 여기에 추가 + implementation(project(":feature:home")) + implementation(project(":feature:curation")) + implementation(project(":feature:file")) + + // Retrofit2 implementation(libs.retrofit2) implementation(libs.retrofit2.converter.gson) @@ -93,4 +100,5 @@ dependencies { ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.hilt.navigation) implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + implementation("androidx.navigation:navigation-compose:2.7.7") } \ No newline at end of file diff --git a/feature/login/src/main/java/com/example/login/LoginApp.kt b/feature/login/src/main/java/com/example/login/LoginApp.kt index 10d914c9..49695f37 100644 --- a/feature/login/src/main/java/com/example/login/LoginApp.kt +++ b/feature/login/src/main/java/com/example/login/LoginApp.kt @@ -1,14 +1,252 @@ 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() +} -//리펙토링 하면서 필요함을 느껴 생성함. 아직 사용X. +/** + * 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(viewModel: LoginViewModel) { - val navigator = rememberNavController() // NavController 생성 - LoginScreen(navigator = navigator) // navigator 전달 +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/ui/item/PasswordLoginTextField.kt b/feature/login/src/main/java/com/example/login/ui/item/PasswordLoginTextField.kt index 6d0917c0..bb4d1984 100644 --- a/feature/login/src/main/java/com/example/login/ui/item/PasswordLoginTextField.kt +++ b/feature/login/src/main/java/com/example/login/ui/item/PasswordLoginTextField.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.tooling.preview.Preview +import com.example.design.modifier.noRippleClickable import com.example.design.theme.LocalColorTheme import com.example.design.util.rememberFigmaDimens import com.example.design.util.scaler @@ -59,6 +60,13 @@ fun PasswordLoginTextField( mutableStateOf(TextFieldValue(text = value)) } + // value 파라미터가 바뀌면 fieldValue 동기화 + LaunchedEffect(value) { + if (fieldValue.text != value) { + fieldValue = TextFieldValue(text = value) + } + } + Box( modifier = modifier .fillMaxWidth() @@ -81,16 +89,17 @@ fun PasswordLoginTextField( Box { OutlinedTextField( value = fieldValue, - onValueChange = { newValue -> - val fixedValue = newValue.copy(composition = null) - fieldValue = fixedValue - onValueChange(fixedValue.text) + onValueChange = { newValue: TextFieldValue -> + fieldValue = newValue + onValueChange(newValue.text) }, placeholder = { Text( text = hint, fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Medium, fontFamily = Paperlogy.font, color = colorTheme.gray[400]!! ) @@ -111,7 +120,8 @@ fun PasswordLoginTextField( fontSize = 14.sp, fontFamily = Paperlogy.font, fontWeight = FontWeight.Bold, - letterSpacing = 2.sp + letterSpacing = 2.sp, + color = colorTheme.black ) }, @@ -126,8 +136,8 @@ fun PasswordLoginTextField( modifier = Modifier .fillMaxSize() - .background(colorTheme.white, shape) - .padding(end = (40.scaler)),// 👁 아이콘 공간 + .background(colorTheme.white, shape), + //.padding(end = (40.scaler)),// 👁 아이콘 공간 shape = shape, @@ -148,12 +158,12 @@ fun PasswordLoginTextField( else R.drawable.ic_password_visibility_off ), - contentDescription = null, + contentDescription = if (isPasswordVisible) "비밀번호 숨기기" else "비밀번호 보기", //직관적으로 수정. modifier = Modifier .align(Alignment.CenterEnd) .padding(end = (18.scaler)) .size((22.scaler)) - .clickable { + .noRippleClickable { // 수정함. isPasswordVisible = !isPasswordVisible } ) diff --git a/feature/login/src/main/java/com/example/login/ui/item/WrongIndicator.kt b/feature/login/src/main/java/com/example/login/ui/item/WrongIndicator.kt new file mode 100644 index 00000000..1413f554 --- /dev/null +++ b/feature/login/src/main/java/com/example/login/ui/item/WrongIndicator.kt @@ -0,0 +1,51 @@ +package com.example.login.ui.item + + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +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.unit.dp +import com.example.design.Negative +import com.example.login.R +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun WrongIndicator( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(18.dp) + .background( + color = Color.Negative, + shape = RoundedCornerShape(5.dp) + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_login_wrong), + contentDescription = "wrong", + modifier = Modifier + .width(9.dp) + .height(9.dp) + ) + } +} + +@Preview( + name = "WrongIndicator", + showBackground = true +) +@Composable +private fun WrongIndicatorPreview() { + WrongIndicator() +} \ No newline at end of file diff --git a/feature/login/src/main/java/com/example/login/ui/item/WrongRuleItem.kt b/feature/login/src/main/java/com/example/login/ui/item/WrongRuleItem.kt new file mode 100644 index 00000000..d229e302 --- /dev/null +++ b/feature/login/src/main/java/com/example/login/ui/item/WrongRuleItem.kt @@ -0,0 +1,74 @@ +package com.example.login.ui.item + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +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.font.FontFamily +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.LocalColorTheme +import com.example.design.theme.font.Paperlogy +import com.example.design.util.rememberFigmaDimens +import com.example.design.util.scaler +import com.example.login.R + + +//회원가입 로직에서 사용하는 체크박스(그 네모 박스에 체크 아이콘 있는거) +@Composable +fun WrongRuleItem( + text: String, + // satisfied: Boolean, + modifier: Modifier = Modifier +) { + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // CheckIndicator(checked = satisfied) //체크박스(활성화/비활성화) + WrongIndicator() + + Spacer(modifier = Modifier.width((8.scaler))) + + Text( + text = text, + fontSize = 13.sp, + fontWeight = FontWeight(400), + fontFamily = Paperlogy.font, + color = Color(0xFFFF5E5E) + ) + } +} + +//프리뷰 +@Preview( + name = "PasswordRuleItem - States", + showBackground = true +) +@Composable +private fun WrongRuleItemPreview() { + + + Column( + modifier = Modifier.padding((16.scaler)), + verticalArrangement = Arrangement.spacedBy((8.scaler)) + ) { + WrongRuleItem( + text = "이미 사용 중인 닉네임입니다.", + + ) + } +} \ No newline at end of file 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/EmailLoginScreen.kt index f4e60715..16e41c8a 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/EmailLoginScreen.kt @@ -38,12 +38,16 @@ 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 @Composable fun EmailLoginScreen( navigator: NavHostController, loginViewModel: LoginViewModel? = null, - onSignUpClick: () -> Unit + onSignUpClick: () -> Unit, + onLoginSuccess: () -> Unit = {} ) { // 1. 키보드 제어를 위한 FocusManager 가져오기 @@ -52,6 +56,22 @@ fun EmailLoginScreen( // 2. 디자인 모듈의 폰트 패밀리 가져오기 val colorTheme = LocalColorTheme.current + // LoginState 관찰 추가 + val loginState by loginViewModel?.loginState?.collectAsStateWithLifecycle() + ?: remember { mutableStateOf(LoginState.Idle) } + + LaunchedEffect(loginState) { + when (loginState) { + is LoginState.Success -> { + focusManager.clearFocus() + onLoginSuccess() + } + is LoginState.Error -> { + focusManager.clearFocus() + } + else -> {} + } + } // 로그인 입력 화면부터는 시스템 바 다시 표시 DesignSystemBars( @@ -80,9 +100,6 @@ fun EmailLoginScreen( val isKeyboardOpen = imeBottom > 0 val buttonOffsetY = if (isKeyboardOpen) 0.dp else (-4.scaler) - // 🔑 BottomGradientButton 내부 padding과 동일한 값 계산 - val navBottom = WindowInsets.navigationBars.getBottom(density) - // 🔑 피그마 비율 적용 @@ -137,7 +154,12 @@ fun EmailLoginScreen( ) { LoginTextField( value = email, - onValueChange = { email = it }, + onValueChange = { + email = it + // 입력 시 에러 초기화. + if (loginState is LoginState.Error) { + loginViewModel?.clearError() + } }, hint = "이메일", textStyle = TextStyle( fontSize = 14.sp, @@ -152,8 +174,35 @@ fun EmailLoginScreen( PasswordLoginTextField( value = password, - onValueChange = { password = it } + onValueChange = { + password = it + // 입력 시 에러 초기화. + if (loginState is LoginState.Error) { + loginViewModel?.clearError() + } + } ) + + // 에러 메시지 추가 + if (loginState is LoginState.Error) { + Spacer(Modifier.height(12.scaler)) + + Box( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = (loginState as LoginState.Error).errorType.message, + style = TextStyle( + fontSize = 13.sp, + lineHeight = 15.sp, + fontFamily = Paperlogy.font, + fontWeight = FontWeight(400), + color = Color(0xFFFF5E5E) + ), + modifier = Modifier.padding(start = 22.scaler) // 오른쪽으로 22만큼 + ) + } + } } Spacer(Modifier.height((45.scaler))) @@ -166,10 +215,11 @@ fun EmailLoginScreen( ) { GradientButtonCore( text = "로그인하기", - enabled = isFormValid, + enabled = isFormValid && loginState !is LoginState.Loading, //로딩 중 비활성화. activeGradient = colorTheme.maincolor, inactiveGradient = colorTheme.inactiveColor, onClick = { + //focusManager.clearFocus() //키보드 내리기 필요하다면 사용하기. loginViewModel?.login( email.trim(), password.trim() @@ -190,7 +240,8 @@ fun EmailLoginScreen( Box( modifier = Modifier .fillMaxWidth() - .height((30.scaler)) // 클릭 영역 확보를 위한 높이 + .height((30.scaler)), // 클릭 영역 확보를 위한 높이 + contentAlignment = Alignment.CenterStart //세로 중앙 정렬 ) { // 1. 비밀번호 재설정 Text( @@ -201,17 +252,22 @@ fun EmailLoginScreen( modifier = Modifier .offset(x = resetStartPos) // 항상 101/412 지점 .noRippleClickable { + //if (loginState !is LoginState.Loading) { -> 혹시 나중에 로딩중이 길어지면 사용해주세요. navigator.navigate("resetPassword") + //} } ) - // 2. 구분선 (|) + // 2. 구분선 (|) // TODO : 비밀번호 재설정, 회원가입 대비 내려가서 부득이하게 약간 위로 올림. 다현이랑 조절해보기. Text( text = "|", fontSize = 14.sp, fontFamily = Paperlogy.font, color = Color(0xFF87898F), - style = TextStyle(baselineShift = BaselineShift(0.15f)), + style = TextStyle( + baselineShift = BaselineShift(0.3f) // 약간 위로 올림 + ), + //style = TextStyle(baselineShift = BaselineShift(0.15f)), modifier = Modifier .offset(x = dividerStartPos) // 항상 220/412 지점 ) @@ -246,7 +302,9 @@ fun EmailLoginPreview() { EmailLoginScreen( navigator = rememberNavController(), loginViewModel = null, - onSignUpClick = {} + onSignUpClick = {}, + onLoginSuccess = {} + ) } 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/EmailVerificationScreen.kt index 7f22e557..039e0030 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/EmailVerificationScreen.kt @@ -24,6 +24,7 @@ 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.design.modifier.noRippleClickable import com.example.login.ui.item.LoginTextField import com.example.login.ui.item.StepIndicator import com.example.login.ui.item.BottomGradientButton @@ -313,6 +314,7 @@ fun EmailVerificationScreenContent( text = "서버 오류: 잠시 후 다시 시도해주세요", color = Color.Red, fontSize = 13.sp, + fontFamily = Paperlogy.font, modifier = Modifier.padding((8.scaler)) ) } @@ -341,7 +343,7 @@ fun EmailVerificationScreenContent( .padding( bottom = (21.scaler) ) - .clickable { + .noRippleClickable { // TODO: 재전송 안내 api 개발시 연동하기! } ) 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/SignUpNicknameScreen.kt index e30de8ca..f97c09a0 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/SignUpNicknameScreen.kt @@ -25,9 +25,12 @@ 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 : 닉네임 매게변수.. -> 사용자 이름 + @Composable fun SignUpNicknameScreen( navigator: NavHostController, @@ -74,7 +77,7 @@ fun SignUpNicknameScreen( color = colorTheme.black ) - Spacer(Modifier.height((12.scaler))) + Spacer(Modifier.height((40.scaler))) LoginTextField( value = nickname, @@ -86,57 +89,29 @@ fun SignUpNicknameScreen( modifier = Modifier.fillMaxWidth() ) - when (nicknameState) { - // 혹시 닉네임 유효성 검사 중 닉네임 확인 중이 필요하다면... -// is NicknameCheckState.Checking -> { -// Spacer(Modifier.height((6.scaler))) -// Text( -// text = "닉네임 확인 중...", -// fontSize = 13.sp, -// lineHeight = 15.sp, -// fontWeight = FontWeight(400), -// fontFamily = Paperlogy.font, -// color = colorTheme.black.copy(alpha = 0.5f) -// ) -// } - + Spacer(Modifier.height((10.scaler))) + // 상태에 따라 다른 컴포넌트 표시 => 수정사항 반영. + when (nicknameState) { is NicknameCheckState.Duplicated -> { - Spacer(Modifier.height((6.scaler))) - Text( + WrongRuleItem( text = "이미 사용 중인 닉네임입니다.", - fontSize = 13.sp, - lineHeight = 15.sp, - fontWeight = FontWeight(400), - fontFamily = Paperlogy.font, - color = Color(0xFFFF5E5E) + modifier = Modifier.padding(start = (12.scaler)) ) } - - - is NicknameCheckState.Error -> { - Spacer(Modifier.height((6.scaler))) - Text( - text = (nicknameState as NicknameCheckState.Error).message, //뷰모델 에러 메시지 사용. - //text = "서버 요청에 실패했습니다.", - fontSize = 13.sp, - lineHeight = 15.sp, - fontWeight = FontWeight(400), - fontFamily = Paperlogy.font, - color = Color(0xFFFF5E5E) + else -> { + PasswordRuleItem( + text = "국문/영문 6자 이하", + satisfied = isNicknameValid, + modifier = Modifier.padding(start = (12.scaler)) ) } - - else -> Unit } - Spacer(Modifier.height((12.scaler))) - PasswordRuleItem( - text = "국문/영문 6자 이하", - satisfied = isNicknameValid, - modifier = Modifier.padding(start = (32.scaler)) - ) + + + Spacer(modifier = Modifier.weight(1f)) } 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/SignUpPasswordScreen.kt index 101e2469..23303be8 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/SignUpPasswordScreen.kt @@ -53,108 +53,113 @@ fun SignUpPasswordScreen( val showConfirmField = isPasswordValid val canProceed = isPasswordValid && doPasswordsMatch + Box(modifier = Modifier.fillMaxSize()) { //디시 보니 버튼 부분이 깨져서 여기도 그냥 박스 넣을게요! + Column( + modifier = Modifier + .fillMaxSize() + .background(colorTheme.white) + .padding( + start = (20.scaler), + end = (20.scaler), + top = (60.scaler), + bottom = (72.scaler) + ), + horizontalAlignment = Alignment.Start + ) { - Column( - modifier = Modifier - .fillMaxSize() - .background(colorTheme.white) - .padding( - start = (20.scaler), - end = (20.scaler), - top = (60.scaler), - bottom = (72.scaler) - ), - horizontalAlignment = Alignment.Start - ) { + StepIndicator( + currentStep = 1, + totalSteps = 3, + label = "계정 정보" + ) - StepIndicator( - currentStep = 1, - totalSteps = 3, - label = "계정 정보" - ) + Spacer(Modifier.height((32.scaler))) - Spacer(Modifier.height((32.scaler))) + Text( + text = "사용하실 비밀번호를\n입력해주세요", + fontSize = 22.sp, + fontFamily = Paperlogy.font, + fontWeight = FontWeight.Bold + ) - Text( - text = "사용하실 비밀번호를\n입력해주세요", - fontSize = 22.sp, - fontFamily = Paperlogy.font, - fontWeight = FontWeight.Bold - ) + Spacer(Modifier.height((32.scaler))) - Spacer(Modifier.height((32.scaler))) + // 비밀번호 입력 : 눈 가리개 있는 것으로 교체 + PasswordLoginTextField( + value = password, + onValueChange = { newPassword -> + signUpViewModel.updateForm { it.copy(password = newPassword) } + }, + hint = "비밀번호를 입력해주세요." + ) - // 비밀번호 입력 : 눈 가리개 있는 것으로 교체 - PasswordLoginTextField( - value = password, - onValueChange = { newPassword -> - signUpViewModel.updateForm { it.copy(password = newPassword) } - }, - hint = "비밀번호를 입력해주세요." - ) + Spacer(Modifier.height((10.scaler))) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = (12.scaler)), + horizontalArrangement = Arrangement.spacedBy((8.scaler)) + ) { + PasswordRuleItem( + text = "영문, 숫자, 특수기호 조합", + satisfied = isPasswordComplex, + modifier = Modifier.weight(1f) + ) + PasswordRuleItem( + text = "8~20자", + satisfied = isPasswordLengthValid, + modifier = Modifier.weight(1f) + ) + } - Spacer(Modifier.height((10.scaler))) + if (showConfirmField) { + Spacer(Modifier.height((20.scaler))) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = (12.scaler)), - horizontalArrangement = Arrangement.spacedBy((8.scaler)) - ) { - PasswordRuleItem( - text = "영문, 숫자, 특수기호 조합", - satisfied = isPasswordComplex, - modifier = Modifier.weight(1f) - ) - PasswordRuleItem( - text = "8~20자", - satisfied = isPasswordLengthValid, - modifier = Modifier.weight(1f) - ) - } + // 비밀번호 확인 눈 가리개 있는 거로 교체 + PasswordLoginTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + hint = "비밀번호를 확인해주세요." + ) - if (showConfirmField) { - Spacer(Modifier.height((20.scaler))) + if (confirmPassword.isNotEmpty() && !doPasswordsMatch) { + Text( + text = "비밀번호가 일치하지 않습니다. 다시 입력해주세요.", + fontSize = 13.sp, + fontFamily = Paperlogy.font, + color = Color(0xFFFF5E5E), + modifier = Modifier.padding( + start = (8.scaler), + top = (4.scaler) + ) + ) + } + } + } - // 비밀번호 확인 눈 가리개 있는 거로 교체 - PasswordLoginTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - hint = "비밀번호를 확인해주세요." - ) + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally + ) { + BottomGradientButton( + text = "다음", + enabled = canProceed, + activeGradient = colorTheme.maincolor, + inactiveGradient = colorTheme.inactiveColor, + onClick = { + navigator.navigate("sign_up_nickname") { + launchSingleTop = true + } + } - if (confirmPassword.isNotEmpty() && !doPasswordsMatch) { - Text( - text = "비밀번호가 일치하지 않습니다. 다시 입력해주세요.", - fontSize = 13.sp, - fontFamily = Paperlogy.font, - color = Color(0xFFFF5E5E), - modifier = Modifier.padding( - start = (8.scaler), - top = (4.scaler) - ) ) } } - Spacer(Modifier.weight(1f)) - - BottomGradientButton( - text = "다음", - enabled = canProceed, - activeGradient = colorTheme.maincolor, - inactiveGradient = colorTheme.inactiveColor, - onClick = { - // 클릭 시점에 최종 확정 저장 지금 굳이 onValueChange에서 계속 저장하고 있기에 필요X. - //signUpViewModel.updateForm { it.copy(password = password) } - navigator.navigate("sign_up_nickname") { - launchSingleTop = true - } - }, - modifier = Modifier.fillMaxWidth() - ) } -} + @Composable @@ -214,8 +219,8 @@ fun SignUpPasswordScreenContent( // 조건 표시(체크박스 활성화/비활성화) Row( modifier = Modifier - .fillMaxWidth() - .padding(start = (12.scaler)), + .fillMaxWidth(), + //.padding(start = (12.scaler)), horizontalArrangement = Arrangement.spacedBy((8.scaler)), verticalAlignment = Alignment.CenterVertically ) { 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 4be0f22a..6b9fb2a7 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 @@ -6,115 +6,221 @@ import androidx.lifecycle.viewModelScope import com.example.core.model.LoginResult import com.example.core.repository.UserRepository import com.example.core.session.SessionStore +import com.example.data.api.ApiError import com.example.data.preference.AuthPreference import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import retrofit2.HttpException -import java.util.concurrent.atomic.AtomicBoolean +import java.io.IOException import javax.inject.Inject +/** + * 세션 정리 + * 1. 로그인 -> 2. 로그인 api 호출 -> 3. 토큰 저장(authPreference) + * 4. 사용자 정보 전체 조회 (GET /api/users/{userId}) -> 5. 세션 풀세팅 + * 6. 로그인 성공(Main 진입하면서 ui는 이미 완성된 세션을 구독함.) + * + * + * 자동 로그인 + * 1. 앱 시작 -> 2. authPreference.isLoggedIn == true로 자동 로그인 판단. + * 3. fetchAndSaveUserSession(userId)- 서버로부터 사용자 정보 받아서 앱 세션 저장소에 만들어 놓음. + * 4. 세션 스토어 풀 세팅함.(api 호출 줄임) -> 5. AutoLoginState.Success한 뒤, 6. 메인 진입. + * */ + + +// 로그인 상태 리펙토링 +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 open class LoginViewModel @Inject constructor( - private val repo: UserRepository, + private val userRepository: UserRepository, private val sessionStore: SessionStore, private val authPreference: AuthPreference, ) : ViewModel() { - // UI가 사용할 단일 상태 - data class LoginState( - val loading: Boolean = false, - val result: LoginResult? = null, // userId, token, status, inactiveDate - val errorTag: String? = null // "INVALID_CREDENTIALS", "SERVER_ERROR" - ) + // 로그인/자동로그인 공통 함수, 마이페이지 조회 → 세션 풀 세팅 + private suspend fun fetchAndSaveUserSession(userId: Long) { + val userInfo = userRepository.getUserInfo(userId) // 사용자 정보 조회 api GET /api/users/{userId} 이용. + + sessionStore.saveLogin( // SessionStore에 세션 생성. + userId = userId, + nickname = userInfo.nickname, + email = userInfo.email, + gender = userInfo.gender, + jobId = userInfo.jobId, + jobName = userInfo.jobName, + myLinku = userInfo.myLinku, + myFolder = userInfo.myFolder, + myAiLinku = userInfo.myAiLinku, + purposes = userInfo.purposes, + interests = userInfo.interests + ) + + Log.d(TAG, "유저 세션 풀 세팅 완료 (ID: $userId)") + } - private val _loginState = MutableStateFlow(LoginState()) + private val _loginState = MutableStateFlow(LoginState.Idle) val loginState: StateFlow = _loginState + private val _autoLoginState = MutableStateFlow(AutoLoginState.Idle) + val autoLoginState: StateFlow = _autoLoginState + fun clearError() { - _loginState.update { it.copy(errorTag = null) } + if (_loginState.value is LoginState.Error) { + _loginState.value = LoginState.Idle + } } + fun login(email: String, password: String) { + // 입력 검증 + if (email.isBlank() || password.isBlank()) { + _loginState.value = LoginState.Error(LoginErrorType.INVALID_CREDENTIALS) + return + } + viewModelScope.launch { - _loginState.value = LoginState(loading = true) try { - val res: LoginResult = repo.login(email, password) - Log.d("LoginViewModel", "로그인 성공: userId=${res.userId}") - - // 토큰 저장은 Repo에서 이미 처리됨. 여기서는 userId/세션만. - authPreference.userId = res.userId?.toLong() - - sessionStore.saveLogin( - userId = res.userId?.toLong() ?: -1L, - nickname = "", - email = "", - gender = "", - jobId = -1L, - jobName = "", - myLinku = -1L, - myFolder = -1L, - myAiLinku= -1L + // 로딩 시작 + _loginState.value = LoginState.Loading + Log.d(TAG, "로그인 시도") + + // API 호출 + val result = userRepository.login( + email = email.trim(), + password = password.trim() ) - _loginState.value = LoginState(loading = false, result = res) - Log.d("LoginViewModel", "login() 완료") + Log.d(TAG, "로그인 성공") + + val userId = result.userId.toLong() + + // 토큰 + userId 저장 + authPreference.saveTokens( + accessToken = result.accessToken, + refreshToken = result.refreshToken, + userId = userId + ) + + // 마이페이지 조회 → 세션 풀 세팅 + fetchAndSaveUserSession(userId) + + // 성공 상태 + _loginState.value = LoginState.Success(result) } catch (e: HttpException) { - Log.e("LoginViewModel", "HttpException: code=${e.code()}, msg=${e.message}") - _loginState.value = LoginState( - loading = false, - errorTag = when (e.code()) { - 401, 403 -> "INVALID_CREDENTIALS" - else -> "SERVER_ERROR" + Log.e(TAG, "로그인 실패 - HTTP 에러: ${e.code()}") + _loginState.value = LoginState.Error( + when (e.code()) { + 401, 403 -> LoginErrorType.INVALID_CREDENTIALS + in 500..599 -> LoginErrorType.SERVER_ERROR + else -> LoginErrorType.UNKNOWN_ERROR } ) - } catch (_: IllegalStateException) { - _loginState.value = LoginState(loading = false, errorTag = "INVALID_CREDENTIALS") - } catch (_: Exception) { - _loginState.value = LoginState(loading = false, errorTag = "SERVER_ERROR") + } catch (e: IOException) { + // 네트워크 에러 별도 처리 + Log.e(TAG, "로그인 실패 - 네트워크 에러", e) + _loginState.value = LoginState.Error(LoginErrorType.NETWORK_ERROR) // _ 추가! + } + catch (e: Exception) { + Log.e(TAG, "로그인 실패", e) + _loginState.value = LoginState.Error(LoginErrorType.UNKNOWN_ERROR) } } } - private val autoLoginTried = AtomicBoolean(false) + // ServerApi.refreshToken() 호출 -> 성공하면 새로운 엑세스 토큰 발급하고 리프레쉬 토큰 저장함. + // 실패하는 경우 토큰 정리함. + // 자동 로그인 시점에 마이페이지 조회 → 세션 풀 세팅함. fun tryAutoLogin( onSuccess: () -> Unit, onFail: () -> Unit ) { - - if (!autoLoginTried.compareAndSet(false, true)) return + if (_autoLoginState.value == AutoLoginState.Checking) return viewModelScope.launch { try { - val refresh = authPreference.refreshToken - ?: throw Exception("No refresh token stored") + _autoLoginState.value = AutoLoginState.Checking - Log.d("LoginViewModel", "자동 로그인 시도 (refresh token 존재)") //로그 정보 유출 방지 + if (!authPreference.isLoggedIn) { + _autoLoginState.value = AutoLoginState.Failed + onFail() + return@launch + } - // 서버에 토큰 재발급 요청 - val newTokens = repo.reissue(refresh) + val userId = authPreference.userId //비정상 상태일 경우에는 + ?: throw IllegalStateException("userId missing") + fetchAndSaveUserSession(userId) //세션 풀세팅 - // 새 토큰 저장 - authPreference.accessToken = newTokens.accessToken - authPreference.refreshToken = newTokens.refreshToken - - Log.d("LoginViewModel", "자동 로그인 성공 → 새로운 토큰 저장 완료") + Log.d(TAG, "자동 로그인 성공") + _autoLoginState.value = AutoLoginState.Success // 이 앱 안에 ui에 바로 쓸 세션이 있음. onSuccess() + } catch (e: ApiError.TokenExpired) { + // 이 경우만 logout + Log.e(TAG, "자동 로그인 실패: 토큰 만료") + authPreference.clear() + _autoLoginState.value = AutoLoginState.Failed + onFail() + } catch (e: Exception) { - Log.e("LoginViewModel", "자동 로그인 실패: ${e.message}", e) + // 나머지는 절대 logout 하지 않음 + Log.e(TAG, "자동 로그인 실패: ${e.message}") + _autoLoginState.value = AutoLoginState.Failed + onFail() + } + } + } - // 자동 로그인 실패 → 토큰 삭제 - authPreference.accessToken = null - authPreference.refreshToken = null - authPreference.userId = null + // 마이페이지 로그아웃 + fun logout(onComplete: () -> Unit) { + viewModelScope.launch { + try { + // 서버에 로그아웃 알림? 필요할까요? + // userRepository.logout() - onFail() + // 로컬 저장소 비우기 (토큰, 유저 아이디 삭제) + authPreference.clear() + + // 인메모리 세션 스토어 비우기 -> 아예 비울 수 있도록. + sessionStore.clear() // SessionStore에 clear() 함수가 있다고 가정 + + Log.d("LoginVM", "로그아웃 및 세션 정리 완료") + onComplete() + } catch (e: Exception) { + Log.e("LoginVM", "로그아웃 중 오류 발생", e) + // 에러가 나더라도 로컬 데이터는 지워야 함 + authPreference.clear() + onComplete() } } } -} \ No newline at end of file + + // 태그 상수 추가함. + companion object { + private const val TAG = "LoginViewModel" + } +} diff --git a/feature/login/src/main/res/drawable/ic_login_wrong.xml b/feature/login/src/main/res/drawable/ic_login_wrong.xml new file mode 100644 index 00000000..cdad1361 --- /dev/null +++ b/feature/login/src/main/res/drawable/ic_login_wrong.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageApp.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageApp.kt index 129fd657..b5ebe2f5 100644 --- a/feature/mypage/src/main/java/com/example/mypage/MyPageApp.kt +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageApp.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -22,85 +23,195 @@ fun MyPageApp( val navController = rememberNavController() val context = LocalContext.current + // 세션 구독 + val session by viewModel.sessionState.collectAsStateWithLifecycle() + // 로그인 시 발급받은 userId 를 보관하고 있다면 그 값을 사용 + // 화면 진입 시 최신 데이터 한 번 긁어오기 LaunchedEffect(Unit) { - viewModel.loadUserInfo() + viewModel.refreshUserInfo() } + //기존 +// LaunchedEffect(Unit) { +// viewModel.loadUserInfo() +// } - val ui by viewModel.uiState.collectAsState() + //val ui by viewModel.uiState.collectAsState() NavHost( navController = navController, startDestination = "mypage" ) { composable("mypage") { - ui.userInfo?.let { user -> - MyPageScreen( - navController = navController, - nickname = user.nickname, - email = user.email, - gender = user.gender, - jobName = user.jobName, - myLinku = user.myLinku, - myFolder = user.myFolder, - myAiLinku = user.myAiLinku, - onNavigateAccount = { navController.navigate("account") }, - onNavigateAlarm = { navController.navigate("alarm") }, - onNavigateQuit = { navController.navigate("quit") }, - onRequestLogout = { - viewModel.logout( - onSuccess = { - android.widget.Toast - .makeText(context, "로그아웃 되었습니다.", android.widget.Toast.LENGTH_SHORT) - .show() - - // 1) 내부 MyPageApp 스택 정리(선택) - navController.popBackStack(route = "mypage", inclusive = true) - // 2) 상위 네비게이터에 로그인 화면으로 이동 요청 - onLogoutToLogin() - }, - onError = { msg -> - android.widget.Toast - .makeText(context, msg, android.widget.Toast.LENGTH_SHORT) - .show() - } - ) - } - ) - } + // 3. sessionSnapshot 데이터를 직접 넘겨줌 + MyPageScreen( + navController = navController, + nickname = session.nickname ?: "", + email = session.email ?: "", + gender = session.gender ?: "", + jobName = session.jobName ?: "", + myLinku = session.myLinku ?: 0L, + myFolder = session.myFolder ?: 0L, + myAiLinku = session.myAiLinku ?: 0L, + onNavigateAccount = { navController.navigate("account") }, + onNavigateAlarm = { navController.navigate("alarm") }, + onNavigateQuit = { navController.navigate("quit") }, + onRequestLogout = { + viewModel.logout( + onSuccess = { + Toast.makeText(context, "로그아웃 되었습니다.", Toast.LENGTH_SHORT).show() + onLogoutToLogin() + }, + onError = { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + ) + } + ) } +// composable("mypage") { +// ui.userInfo?.let { user -> +// MyPageScreen( +// navController = navController, +// nickname = user.nickname, +// email = user.email, +// gender = user.gender, +// jobName = user.jobName, +// myLinku = user.myLinku, +// myFolder = user.myFolder, +// myAiLinku = user.myAiLinku, +// onNavigateAccount = { navController.navigate("account") }, +// onNavigateAlarm = { navController.navigate("alarm") }, +// onNavigateQuit = { navController.navigate("quit") }, +// onRequestLogout = { +// viewModel.logout( +// onSuccess = { +// android.widget.Toast +// .makeText(context, "로그아웃 되었습니다.", android.widget.Toast.LENGTH_SHORT) +// .show() +// +// // 1) 내부 MyPageApp 스택 정리(선택) +// navController.popBackStack(route = "mypage", inclusive = true) +// // 2) 상위 네비게이터에 로그인 화면으로 이동 요청 +// onLogoutToLogin() +// }, +// onError = { msg -> +// android.widget.Toast +// .makeText(context, msg, android.widget.Toast.LENGTH_SHORT) +// .show() +// } +// ) +// } +// ) +// } +// } composable("account") { - ui.userInfo?.let { user -> + // nickname이나 purposes가 로드될 때까지 기다립니다. + android.util.Log.d("MyPageApp", "태그 데이터 확인 - Purposes: ${session.purposes}, Interests: ${session.interests}") + if (session.nickname != null) { AccountSettingScreen( navController = navController, - nicknamePlaceholder = user.nickname, - jobPlaceholder = user.jobName, - initialPurposeTags = user.purposes.toSet(), - initialContentTags = user.interests.toSet(), - onSubmit = { nickname, jobId, purposes, interests -> + nicknamePlaceholder = session.nickname ?: "", + jobPlaceholder = session.jobName ?: "", + initialPurposeTags = session.purposes.toSet(), + initialContentTags = session.interests.toSet(), + onSubmit = { nickname, jobId, jobName, purposes, interests -> viewModel.updateUserInfo( nickname = nickname, jobId = jobId, + jobName = jobName, purposes = purposes, interests = interests, onSuccess = { - android.widget.Toast - .makeText(context, "변경되었습니다.", android.widget.Toast.LENGTH_SHORT) - .show() - // 최신 데이터는 loadUserInfo()에서 이미 갱신됨 - // MyPageScreen 으로 복귀 + Toast.makeText(context, "변경되었습니다.", Toast.LENGTH_SHORT).show() navController.popBackStack("mypage", inclusive = false) }, onError = { msg -> - android.widget.Toast - .makeText(context, msg, android.widget.Toast.LENGTH_SHORT) - .show() + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } ) } ) + } else { + // 로딩 중일 때 보여줄 화면 (잠시 빈 화면 혹은 프로그레스바) + // 아무것도 안 써두면 데이터가 올 때까지 잠깐 멈춰있다가 나타납니다. } +// AccountSettingScreen( +// navController = navController, +// nicknamePlaceholder = session.nickname ?: "", +// jobPlaceholder = session.jobName ?: "", +// // 세션에서 바로 가져올 수 있음! api 호출 줄임. +// initialPurposeTags = session.purposes.toSet(), // 세션에서 가져옴 +// initialContentTags = session.interests.toSet(), // 세션에서 가져옴 +// onSubmit = { nickname, jobId, jobName, purposes, interests -> +// /** +// * TODO: 지현이에게 전달 +// * +// * [사용자 정보 수정 방법] +// * 아래 함수 호출하면 자동으로: +// * 1. 서버 API 호출 (DB 수정) +// * 2. 로컬 세션 업데이트 (UI 즉시 반영) +// * +// * +// * - nickname: 새 닉네임 +// * - jobId: 직업 ID (Long) +// * - jobName: 직업 이름 (UI 표시용) +// * - purposes: 사용 목적 리스트 (한글 그대로 전달) +// * ex) listOf("취업·커리어 준비", "학업/리포트 정리") +// * - interests: 관심 콘텐츠 리스트 (한글 그대로 전달) +// * ex) listOf("IT/개발", "비즈니스/마케팅") +// * +// * jobName은 선택한 직업의 이름을 넘겨야 UI에 바로 반영 +// */ +// viewModel.updateUserInfo( +// nickname = nickname, +// jobId = jobId, +// jobName = jobName,// 직업 이름. +// purposes = purposes, +// interests = interests, +// onSuccess = { +// Toast.makeText(context, "변경되었습니다.", Toast.LENGTH_SHORT).show() +// navController.popBackStack("mypage", inclusive = false) +// }, +// onError = { msg -> +// Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() +// } +// ) +// } +// ) } +// composable("account") { +// ui.userInfo?.let { user -> +// AccountSettingScreen( +// navController = navController, +// nicknamePlaceholder = user.nickname, +// jobPlaceholder = user.jobName, +// initialPurposeTags = user.purposes.toSet(), +// initialContentTags = user.interests.toSet(), +// onSubmit = { nickname, jobId, purposes, interests -> +// viewModel.updateUserInfo( +// nickname = nickname, +// jobId = jobId, +// purposes = purposes, +// interests = interests, +// onSuccess = { +// android.widget.Toast +// .makeText(context, "변경되었습니다.", android.widget.Toast.LENGTH_SHORT) +// .show() +// // 최신 데이터는 loadUserInfo()에서 이미 갱신됨 +// // MyPageScreen 으로 복귀 +// navController.popBackStack("mypage", inclusive = false) +// }, +// onError = { msg -> +// android.widget.Toast +// .makeText(context, msg, android.widget.Toast.LENGTH_SHORT) +// .show() +// } +// ) +// } +// ) +// } +// } composable("alarm") { AlarmSettingScreen(navController = navController) } composable("quit") { ServiceQuitScreen( 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 c20a8ffb..18392e6e 100644 --- a/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt @@ -5,67 +5,96 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.core.model.UserInfo import com.example.core.repository.UserRepository +import com.example.core.session.SessionStore import com.example.data.preference.AuthPreference import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MyPageViewModel @Inject constructor( private val userRepository: UserRepository, + private val sessionStore: SessionStore, //세션 스토어 추가. private val authPreference: AuthPreference ): ViewModel() { - data class UiState( - val isLoading: Boolean = false, - val userInfo: UserInfo? = null, - val error: String? = null - ) + // 별도 로딩(api 호출 없이) 로컬에 저장된 데이터 바로 보여줌. + val sessionState: StateFlow = sessionStore.session + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = SessionStore.SessionSnapshot(false, null, + null, null, null, null, null, null, null, null, + emptyList(), emptyList() ) + ) - private val _uiState = MutableStateFlow(UiState(isLoading = true)) - val uiState: StateFlow = _uiState.asStateFlow() - - // 마이페이지 조회 - private val _userInfo = MutableStateFlow(null) - val userInfo: StateFlow = _userInfo - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading - - private val _error = MutableStateFlow(null) - val error: StateFlow = _error - - // 마이페이지 조회 - fun loadUserInfo() { - val id = authPreference.userId - if (id == null || id <= 0L) { - _uiState.value = UiState( - isLoading = false, - userInfo = null, - error = "로그인이 필요합니다." - ) - return - } +// data class UiState( +// val isLoading: Boolean = false, +// val userInfo: UserInfo? = null, +// val error: String? = null +// ) +// +// private val _uiState = MutableStateFlow(UiState(isLoading = true)) +// val uiState: StateFlow = _uiState.asStateFlow() +// +// // 마이페이지 조회 +// private val _userInfo = MutableStateFlow(null) +// val userInfo: StateFlow = _userInfo +// +// private val _isLoading = MutableStateFlow(false) +// val isLoading: StateFlow = _isLoading +// +// private val _error = MutableStateFlow(null) +// val error: StateFlow = _error +// +// // 마이페이지 조회 +// fun loadUserInfo() { +// val id = authPreference.userId +// if (id == null || id <= 0L) { +// _uiState.value = UiState( +// isLoading = false, +// userInfo = null, +// error = "로그인이 필요합니다." +// ) +// return +// } +//// viewModelScope.launch { +//// _isLoading.value = true +//// _error.value = null +//// runCatching { userRepository.getUserInfo(userId) } +//// .onSuccess { _userInfo.value = it } +//// .onFailure { _error.value = it.message ?: "마이페이지 조회 실패" } +//// _isLoading.value = false +//// } +// _uiState.value = _uiState.value.copy(isLoading = true, error = null) // viewModelScope.launch { -// _isLoading.value = true -// _error.value = null -// runCatching { userRepository.getUserInfo(userId) } -// .onSuccess { _userInfo.value = it } -// .onFailure { _error.value = it.message ?: "마이페이지 조회 실패" } -// _isLoading.value = false +// runCatching { userRepository.getUserInfo(id) } +// .onSuccess { info -> +// _uiState.value = UiState(isLoading = false, userInfo = info) +// } +// .onFailure { e -> +// _uiState.value = UiState(isLoading = false, error = e.message ?: "마이페이지 조회 실패") +// } // } - _uiState.value = _uiState.value.copy(isLoading = true, error = null) +// } + + // 마이페이지 진입 시 최신 정보 갱신 용도로 사용함. + fun refreshUserInfo() { + val id = authPreference.userId ?: return viewModelScope.launch { - runCatching { userRepository.getUserInfo(id) } - .onSuccess { info -> - _uiState.value = UiState(isLoading = false, userInfo = info) - } - .onFailure { e -> - _uiState.value = UiState(isLoading = false, error = e.message ?: "마이페이지 조회 실패") - } + runCatching { + userRepository.getUserInfo(id) + }.onFailure { e -> + Log.e("MyPageViewModel", "데이터 동기화 실패: ${e.message}") + } + // 따로 상태를 업데이트할 필요가X. + // UserRepositoryImpl 내부의 .also 블록이 세션을 업데이트하면 + // 위 1번의 sessionState가 자동으로 UI를 갱신합니다. } } @@ -73,6 +102,7 @@ class MyPageViewModel @Inject constructor( fun updateUserInfo( nickname: String, jobId: Long, + jobName: String, //UI 즉시 반영을 위해 추가 purposes: List, interests: List, onSuccess: () -> Unit, @@ -80,10 +110,14 @@ 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() + //loadUserInfo() + + // 서버 성공 시(DB 변경 성공시) 세션만 즉시 업데이트(ui 자동 갱신) + sessionStore.updateProfile(nickname, jobId, jobName, purposes, interests) onSuccess() } else { onError("변경에 실패했습니다.") @@ -107,9 +141,8 @@ class MyPageViewModel @Inject constructor( Log.d("MyPageViewModel", "✅ 회원 탈퇴 성공") // 토큰/세션 정리 - authPreference.accessToken = null - authPreference.refreshToken = null - authPreference.userId = null + authPreference.clear() + sessionStore.clear() onSuccess() } catch (e: Exception) { @@ -120,14 +153,25 @@ class MyPageViewModel @Inject constructor( } // 로그아웃 */ - fun logout(onSuccess: () -> Unit, onError: (String) -> Unit) { - viewModelScope.launch { - try { - userRepository.logout() // 서버 로그아웃 + 토큰/유저ID 정리까지 Repository에서 처리 - onSuccess() - } catch (e: Exception) { - onError("로그아웃에 실패했습니다.") - } + fun logout(onSuccess: () -> Unit, onError: (String) -> Unit) { + viewModelScope.launch { + try { + userRepository.logout() + onSuccess() + } catch (e: Exception) { + onError("로그아웃에 실패했습니다.") } } + } +// fun logout(onSuccess: () -> Unit, onError: (String) -> Unit) { +// viewModelScope.launch { +// try { +// userRepository.logout() // 서버 로그아웃 + 토큰/유저ID 정리까지 Repository에서 처리 +// _uiState.value = UiState() // 마이페이지 상태 초기화 +// onSuccess() +// } catch (e: Exception) { +// onError("로그아웃에 실패했습니다.") +// } +// } +// } } \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/example/mypage/screen/AccountSettingScreen.kt b/feature/mypage/src/main/java/com/example/mypage/screen/AccountSettingScreen.kt index a015cf8a..5ec69777 100644 --- a/feature/mypage/src/main/java/com/example/mypage/screen/AccountSettingScreen.kt +++ b/feature/mypage/src/main/java/com/example/mypage/screen/AccountSettingScreen.kt @@ -62,7 +62,8 @@ fun AccountSettingScreen( jobPlaceholder: String, initialPurposeTags: Set = emptySet(), initialContentTags: Set = emptySet(), - onSubmit: (nickname: String, jobId: Long, purposes: List, interests: List) -> Unit + onSubmit: (nickname: String, jobId: Long, jobName: String, purposes: List, interests: List) -> Unit + //onSubmit: (nickname: String, jobId: Long, purposes: List, interests: List) -> Unit ) { val username = nicknamePlaceholder val userjob = jobPlaceholder @@ -339,6 +340,7 @@ fun AccountSettingScreen( onSubmit( finalNickname, jobId, + selectedJob, selectedPurposeTags.toList(), selectedContentTags.toList() ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f315805b..e56ea5bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,8 @@ foundation = "1.9.5" composeTesting = "1.0.0-alpha09" toolsCore = "1.0.0-alpha14" foundationVersion = "1.10.1" +foundationLayout = "1.10.0" + [libraries] @@ -84,6 +86,8 @@ 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" } + diff --git a/ic_login_wrong.svg b/ic_login_wrong.svg new file mode 100644 index 00000000..212aa681 --- /dev/null +++ b/ic_login_wrong.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/file/src/main/java/com/example/file/TestActivity.kt b/test/file/src/main/java/com/example/file/TestActivity.kt index bcf1e06f..4827efc9 100644 --- a/test/file/src/main/java/com/example/file/TestActivity.kt +++ b/test/file/src/main/java/com/example/file/TestActivity.kt @@ -12,7 +12,7 @@ class TestActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - FileScreen() + FileApp() } } } \ No newline at end of file diff --git a/test/login/build.gradle.kts b/test/login/build.gradle.kts index 6ca9b25f..01332cdd 100644 --- a/test/login/build.gradle.kts +++ b/test/login/build.gradle.kts @@ -70,6 +70,8 @@ dependencies { implementation(project(":data")) implementation(project(":feature:login")) + + // Retrofit2 implementation(libs.retrofit2) implementation(libs.retrofit2.converter.gson)