diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63d78e7..8aadcd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,5 +73,6 @@ dependencies { implementation(project(":feature:download:impl")) implementation(project(":feature:resume:impl")) implementation(project(":feature:resume_view:impl")) - implementation(project(":feature:setting")) + implementation(project(":feature:mypage:api")) + implementation(project(":feature:mypage:impl")) } \ No newline at end of file diff --git a/app/src/main/java/com/nonggle/nonggleresume/MainActivity.kt b/app/src/main/java/com/nonggle/nonggleresume/MainActivity.kt index c0e440c..093313f 100644 --- a/app/src/main/java/com/nonggle/nonggleresume/MainActivity.kt +++ b/app/src/main/java/com/nonggle/nonggleresume/MainActivity.kt @@ -8,6 +8,8 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState @@ -97,7 +99,10 @@ class MainActivity : ComponentActivity() { darkTheme = themeSettings.darkTheme, ) { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .statusBarsPadding(), color = NonggleTheme.colorScheme.white ) { if (isLoggedIn) { @@ -133,8 +138,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() - /// TODO: loading 상태 true로 업데이트 - /// TODO: 백그라운드 -> 포그라운드 전환일 경우 로그인 유효성을 미리 확인 + viewModel.onResume() } override fun onPause() { diff --git a/app/src/main/java/com/nonggle/nonggleresume/MainActivityViewModel.kt b/app/src/main/java/com/nonggle/nonggleresume/MainActivityViewModel.kt index 333e1f5..1438140 100644 --- a/app/src/main/java/com/nonggle/nonggleresume/MainActivityViewModel.kt +++ b/app/src/main/java/com/nonggle/nonggleresume/MainActivityViewModel.kt @@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,7 +18,6 @@ import javax.inject.Inject class MainActivityViewModel @Inject constructor( authEventBus: AuthEventBus, private val loginRepository: LoginRepository, - // TODO: Add a UseCase to check the initial login status ) : ViewModel() { private val _isLoggedIn = MutableStateFlow(false) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() @@ -26,29 +26,39 @@ class MainActivityViewModel @Inject constructor( val uiState: StateFlow = _uiState init { + refreshLoginState() + viewModelScope.launch { - loginRepository.isLoggedIn().collect { isLoggedIn -> - _isLoggedIn.value = isLoggedIn - } - } - // Listen for session expiration events - viewModelScope.launch { - /// TODO: 로그인 상태 확인 -> 토큰이 정상적으로 존재하는지 확인 - // _isLoggedIn.value = authRepository.isLoggedIn() authEventBus.events.collect { event -> - if (event is AuthEvent.SessionExpired) { - loginRepository.logOut() // 토큰 삭제 - _isLoggedIn.value = false // 이것만으로 로그인 화면 전환 + backstack 자동 소멸 + when (event) { + AuthEvent.SessionExpired -> { + loginRepository.logOut() + _isLoggedIn.value = false + } + + AuthEvent.LoggedOut -> { + _isLoggedIn.value = false + } } } } - - _uiState.value = MainActivityUiState.Success } fun onLoginSuccess() { _isLoggedIn.value = true } + + fun onResume() { + refreshLoginState() + } + + private fun refreshLoginState() { + viewModelScope.launch { + _uiState.value = MainActivityUiState.Loading + _isLoggedIn.value = loginRepository.isLoggedIn().first() + _uiState.value = MainActivityUiState.Success + } + } } sealed interface MainActivityUiState { @@ -58,9 +68,6 @@ sealed interface MainActivityUiState { override fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) = isSystemDarkTheme } - // 스플래시 화면 상태를 유지해야하는지에 대한 여부 - fun shouldKeepSplashScreen() = this is Loading - // 다크 테마 사용 필요성 fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) = isSystemDarkTheme -} \ No newline at end of file +} diff --git a/app/src/main/java/com/nonggle/nonggleresume/navigation/TopLevelNavItem.kt b/app/src/main/java/com/nonggle/nonggleresume/navigation/TopLevelNavItem.kt index 633cfd5..630f766 100644 --- a/app/src/main/java/com/nonggle/nonggleresume/navigation/TopLevelNavItem.kt +++ b/app/src/main/java/com/nonggle/nonggleresume/navigation/TopLevelNavItem.kt @@ -6,8 +6,8 @@ import androidx.compose.ui.res.stringResource import com.nonggle.designsystem.icon.NonggleIcons import com.nonggle.api.HomeNavKey import com.nonggle.nonggleresume.R -import com.nonggle.setting.navigation.SettingNavKey import com.nonggle.feature.download.api.DownLoadNavKey +import com.nonggle.mypage.api.MyPageNavKey data class TopLevelNavItem( @DrawableRes val selectedIconRes: Int, @@ -27,7 +27,7 @@ val DOWNLOAD = TopLevelNavItem( title = { stringResource(R.string.nav_item_download) } ) -val SETTING = TopLevelNavItem( +val MYPAGE = TopLevelNavItem( selectedIconRes = NonggleIcons.settingSelected, unselectedIconRes = NonggleIcons.settingUnselected, title = { stringResource(R.string.nav_item_setting) } @@ -36,5 +36,5 @@ val SETTING = TopLevelNavItem( val TOP_LEVEL_NAV_ITEMS = mapOf( HomeNavKey to HOME, DownLoadNavKey to DOWNLOAD, - SettingNavKey to SETTING, + MyPageNavKey to MYPAGE, ) diff --git a/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleApp.kt b/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleApp.kt index 8bbabba..fa47c4b 100644 --- a/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleApp.kt +++ b/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleApp.kt @@ -1,6 +1,7 @@ package com.nonggle.nonggleresume.ui -import android.util.Log +import android.widget.Toast +import com.nonggle.nonggleresume.R import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -19,7 +20,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay @@ -30,8 +33,8 @@ import com.nonggle.navigation.toEntries import com.nonggle.feature.home.impl.navigation.homeEntryProvider import com.nonggle.feature.resume_view.impl.navigation.resumeViewEntryProvider import com.nonggle.nonggleresume.navigation.TOP_LEVEL_NAV_ITEMS -import com.nonggle.setting.navigation.settingEntryProvider import com.nonggle.feature.download.impl.navigation.downLoadEntryProvider +import com.nonggle.mypage.impl.navigation.myPageEntryProvider import com.nonggle.resume.impl.navigation.resumeEntryProvider // 로그인 후에만 호출됨 @@ -42,9 +45,13 @@ internal fun NonggleApp( appState: NonggleAppState, ) { val isOffline by appState.isOffline.collectAsStateWithLifecycle() + val context = LocalContext.current + val offlineToastMessage = stringResource(R.string.offline_Message) - LaunchedEffect(isOffline) {/// TODO: 네트워크 미연결시 다이얼로그 처리 - if (isOffline) Log.d("NOTCONNECT", "네트워크 미연결") + LaunchedEffect(isOffline) { + if (isOffline) { + Toast.makeText(context, offlineToastMessage, Toast.LENGTH_LONG).show() + } } val mainNavigator = remember { Navigator(appState.mainNavigationState) } @@ -84,7 +91,7 @@ internal fun NonggleApp( resumeEntryProvider(mainNavigator) resumeViewEntryProvider(mainNavigator) downLoadEntryProvider(mainNavigator) - settingEntryProvider(mainNavigator) + myPageEntryProvider(mainNavigator) } NavDisplay( diff --git a/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleAppState.kt b/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleAppState.kt index 2744b51..bb38fb5 100644 --- a/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleAppState.kt +++ b/app/src/main/java/com/nonggle/nonggleresume/ui/NonggleAppState.kt @@ -45,5 +45,4 @@ class NonggleAppState( started = SharingStarted.WhileSubscribed(5_000), initialValue = false ) - } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..ae30e1f 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -4,9 +4,11 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> + + - - - - - - - - - + android:viewportWidth="130" + android:viewportHeight="130"> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 75% rename from app/src/main/res/mipmap-anydpi/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755..bbd3e02 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 75% rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755..bbd3e02 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78..25b8a07 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d..718dc26 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d6..b1a35ff 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611d..673286a 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a307..d51c59b 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a695..f329959 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77..cb3abbb 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f50..f2d594d 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d642..49a9403 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae3..0639019 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec4d4b3..09bcb73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,6 @@ 농글농글 다운로드 - 설정 + 마이페이지 + 농글 서비스에 연결할 수 없습니다. 네트워크 상태를 확인해주세요. \ No newline at end of file diff --git a/app/src/test/java/com/nonggle/nonggleresume/MainActivityViewModelTest.kt b/app/src/test/java/com/nonggle/nonggleresume/MainActivityViewModelTest.kt index 2740563..a90ef87 100644 --- a/app/src/test/java/com/nonggle/nonggleresume/MainActivityViewModelTest.kt +++ b/app/src/test/java/com/nonggle/nonggleresume/MainActivityViewModelTest.kt @@ -63,4 +63,45 @@ class MainActivityViewModelTest { coVerify(exactly = 1) { loginRepository.logOut() } assertFalse(viewModel.isLoggedIn.value) } + + @Test + fun `LoggedOut 이벤트 수신시 isLoggedIn을 false로 변경하고 logOut을 다시 호출하지 않는다`() = runTest { + coEvery { loginRepository.isLoggedIn() } returns flowOf(true) + + val viewModel = MainActivityViewModel( + authEventBus = authEventBus, + loginRepository = loginRepository, + ) + + advanceUntilIdle() + + authEventBus.emit(AuthEvent.LoggedOut) + + advanceUntilIdle() + + coVerify(exactly = 0) { loginRepository.logOut() } + assertFalse(viewModel.isLoggedIn.value) + } + + @Test + fun `onResume 호출시 로그인 상태를 다시 확인해 isLoggedIn을 갱신한다`() = runTest { + coEvery { loginRepository.isLoggedIn() } returnsMany listOf( + flowOf(true), + flowOf(false) + ) + + val viewModel = MainActivityViewModel( + authEventBus = authEventBus, + loginRepository = loginRepository, + ) + + advanceUntilIdle() + assertFalse(viewModel.uiState.value is MainActivityUiState.Loading) + + viewModel.onResume() + + advanceUntilIdle() + + assertFalse(viewModel.isLoggedIn.value) + } } diff --git a/core/common/src/main/java/com/nonggle/common/result/AuthEventBus.kt b/core/common/src/main/java/com/nonggle/common/result/AuthEventBus.kt index 86170a8..23e2923 100644 --- a/core/common/src/main/java/com/nonggle/common/result/AuthEventBus.kt +++ b/core/common/src/main/java/com/nonggle/common/result/AuthEventBus.kt @@ -8,6 +8,7 @@ import javax.inject.Singleton sealed interface AuthEvent { data object SessionExpired : AuthEvent + data object LoggedOut : AuthEvent } interface AuthEventBus { @@ -23,4 +24,4 @@ class DefaultAuthEventBus @Inject constructor() : AuthEventBus { override suspend fun emit(event: AuthEvent) { _events.emit(event) } -} \ No newline at end of file +} diff --git a/core/data/src/main/java/com/nonggle/data/repositoryimpl/LoginRepositoryImpl.kt b/core/data/src/main/java/com/nonggle/data/repositoryimpl/LoginRepositoryImpl.kt index 6e2f89c..6f956b1 100644 --- a/core/data/src/main/java/com/nonggle/data/repositoryimpl/LoginRepositoryImpl.kt +++ b/core/data/src/main/java/com/nonggle/data/repositoryimpl/LoginRepositoryImpl.kt @@ -42,8 +42,13 @@ class LoginRepositoryImpl @Inject constructor( } override suspend fun logOut(): AppResult { - /// TODO: 로그아웃 구현 - return AppResult.Success(Unit) + val response = loginService.logout() + tokenManager.deleteToken() + + return when(response) { + is AppResult.Success -> AppResult.Success(Unit) + is AppResult.Error -> response + } } -} \ No newline at end of file +} diff --git a/core/data/src/main/java/com/nonggle/data/repositoryimpl/ResumeDraftStore.kt b/core/data/src/main/java/com/nonggle/data/repositoryimpl/ResumeDraftStore.kt index cf61449..97747d7 100644 --- a/core/data/src/main/java/com/nonggle/data/repositoryimpl/ResumeDraftStore.kt +++ b/core/data/src/main/java/com/nonggle/data/repositoryimpl/ResumeDraftStore.kt @@ -11,12 +11,12 @@ import javax.inject.Singleton @Singleton class ResumeDraftStore @Inject constructor(): ResumeDraftStoreInterface { private val _draft = MutableStateFlow(ResumeWritingModel()) - val draft: StateFlow = _draft + override val draft: StateFlow = _draft - override fun update(reducer: (ResumeWritingModel) -> ResumeWritingModel) { + override suspend fun update(reducer: (ResumeWritingModel) -> ResumeWritingModel) { _draft.update(reducer) } - override fun snapshot(): ResumeWritingModel = _draft.value + override suspend fun snapshot(): ResumeWritingModel = _draft.value -} \ No newline at end of file +} diff --git a/core/designsystem/src/main/java/com/nonggle/designsystem/component/Button.kt b/core/designsystem/src/main/java/com/nonggle/designsystem/component/Button.kt index 808dafe..3d8d99c 100644 --- a/core/designsystem/src/main/java/com/nonggle/designsystem/component/Button.kt +++ b/core/designsystem/src/main/java/com/nonggle/designsystem/component/Button.kt @@ -260,7 +260,7 @@ private fun NonggleButtonPreview() { NonggleButton( contentColor = NonggleTheme.colorScheme.white, backgroundColor = NonggleTheme.colorScheme.m1, - onClick = { /*TODO*/ } + onClick = { } ) { Text("Nonggle Button") } diff --git a/core/designsystem/src/main/java/com/nonggle/designsystem/component/Navigation.kt b/core/designsystem/src/main/java/com/nonggle/designsystem/component/Navigation.kt index f444bf5..b075ae4 100644 --- a/core/designsystem/src/main/java/com/nonggle/designsystem/component/Navigation.kt +++ b/core/designsystem/src/main/java/com/nonggle/designsystem/component/Navigation.kt @@ -1,7 +1,9 @@ package com.nonggle.designsystem.component +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults @@ -45,12 +47,19 @@ fun NonggleNavigationBar( modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit, ) { - NavigationBar( - modifier = modifier, - contentColor = Color.Green, - tonalElevation = 0.dp, - content = content, - ) + Column { + HorizontalDivider( + thickness = 1.dp, + color = NonggleTheme.colorScheme.g_line + ) + NavigationBar( + modifier = modifier, + contentColor = NonggleTheme.colorScheme.m1, + containerColor = NonggleTheme.colorScheme.white, + tonalElevation = 3.dp, + content = content, + ) + } } @Composable diff --git a/core/designsystem/src/main/java/com/nonggle/designsystem/component/TopAppBar.kt b/core/designsystem/src/main/java/com/nonggle/designsystem/component/TopAppBar.kt index f3cfd18..8c0ebe5 100644 --- a/core/designsystem/src/main/java/com/nonggle/designsystem/component/TopAppBar.kt +++ b/core/designsystem/src/main/java/com/nonggle/designsystem/component/TopAppBar.kt @@ -61,7 +61,8 @@ fun NonggleTopAppBar( @OptIn(ExperimentalMaterial3Api::class) @Composable fun NonggleMainTopAppBar( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + appBarTitle: String, ) { TopAppBar( modifier = modifier, @@ -74,7 +75,7 @@ fun NonggleMainTopAppBar( ), title = { Text( - text = stringResource(R.string.app_name), + text = appBarTitle, color = NonggleTheme.colorScheme.m1, style = TextStyle( fontFamily = soYo, @@ -111,6 +112,6 @@ private fun NonggleTopAppBarPreview() { @Composable private fun NonggleMainTopAppBarPreview() { NonggleTheme { - NonggleMainTopAppBar() + NonggleMainTopAppBar(appBarTitle = "농글") } } diff --git a/core/domain/src/main/java/com/nonggle/domain/repository/ResumeDraftStoreInterface.kt b/core/domain/src/main/java/com/nonggle/domain/repository/ResumeDraftStoreInterface.kt index 881bce6..25cc517 100644 --- a/core/domain/src/main/java/com/nonggle/domain/repository/ResumeDraftStoreInterface.kt +++ b/core/domain/src/main/java/com/nonggle/domain/repository/ResumeDraftStoreInterface.kt @@ -1,8 +1,11 @@ package com.nonggle.domain.repository import com.nonggle.model.ResumeWritingModel +import kotlinx.coroutines.flow.StateFlow interface ResumeDraftStoreInterface { - fun update(reducer: (ResumeWritingModel) -> ResumeWritingModel) + val draft: StateFlow - fun snapshot(): ResumeWritingModel -} \ No newline at end of file + suspend fun update(reducer: (ResumeWritingModel) -> ResumeWritingModel) + + suspend fun snapshot(): ResumeWritingModel +} diff --git a/core/domain/src/main/java/com/nonggle/domain/usecase/LogoutUseCase.kt b/core/domain/src/main/java/com/nonggle/domain/usecase/LogoutUseCase.kt new file mode 100644 index 0000000..78d8cb8 --- /dev/null +++ b/core/domain/src/main/java/com/nonggle/domain/usecase/LogoutUseCase.kt @@ -0,0 +1,13 @@ +package com.nonggle.domain.usecase + +import com.nonggle.domain.repository.LoginRepository +import com.nonggle.model.AppResult +import javax.inject.Inject + +class LogoutUseCase @Inject constructor( + private val loginRepository: LoginRepository +) { + suspend operator fun invoke(): AppResult { + return loginRepository.logOut() + } +} \ No newline at end of file diff --git a/core/network/src/main/java/com/nonggle/network/service/LoginService.kt b/core/network/src/main/java/com/nonggle/network/service/LoginService.kt index e37951e..ce2754b 100644 --- a/core/network/src/main/java/com/nonggle/network/service/LoginService.kt +++ b/core/network/src/main/java/com/nonggle/network/service/LoginService.kt @@ -15,6 +15,8 @@ import javax.inject.Inject interface LoginService { suspend fun kakaoLogin(accessToken: String): AppResult + + suspend fun logout(): AppResult } class LoginServiceImpl @Inject constructor( @@ -28,4 +30,10 @@ class LoginServiceImpl @Inject constructor( } } } + + override suspend fun logout(): AppResult { + return safeApiCall(ioDispatcher) { + baseClient.post("/auth/logout") + } + } } \ No newline at end of file diff --git a/feature/download/impl/src/main/java/com/nonggle/feature/download/impl/DownloadScreen.kt b/feature/download/impl/src/main/java/com/nonggle/feature/download/impl/DownloadScreen.kt index 365116c..16df8e2 100644 --- a/feature/download/impl/src/main/java/com/nonggle/feature/download/impl/DownloadScreen.kt +++ b/feature/download/impl/src/main/java/com/nonggle/feature/download/impl/DownloadScreen.kt @@ -44,6 +44,7 @@ import coil3.transform.CircleCropTransformation import com.nonggle.designsystem.component.FullButton import com.nonggle.designsystem.component.NonggleDialog import com.nonggle.designsystem.component.NonggleIconButton +import com.nonggle.designsystem.component.NonggleMainTopAppBar import com.nonggle.designsystem.theme.NonggleTheme import com.nonggle.feature.download.impl.R import com.nonggle.feature.download.impl.DownloadEvent @@ -80,21 +81,31 @@ internal fun DownloadScreen( Column( modifier = modifier .fillMaxSize() - .padding(horizontal = 20.dp, vertical = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + .padding(horizontal = 20.dp), ) { + NonggleMainTopAppBar(appBarTitle = stringResource(R.string.Download_Title)) if (uiState.isLoading == true) { - CircularProgressIndicator() + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } else if (uiState.isError == true) { - FullButton( - modifier = Modifier - .fillMaxWidth(), - title = stringResource(R.string.Download_Title_RetryButton), - onClick = { onEvent(DownloadEvent.RetryGetResumeList) } - ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + FullButton( + modifier = Modifier + .fillMaxWidth(), + title = stringResource(R.string.Download_Title_RetryButton), + onClick = { onEvent(DownloadEvent.RetryGetResumeList) } + ) + } } else { LazyColumn( + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( @@ -238,4 +249,4 @@ fun deleteDialog( ) } ) -} \ No newline at end of file +} diff --git a/feature/download/impl/src/main/res/values/strings.xml b/feature/download/impl/src/main/res/values/strings.xml index 9c8196a..df8f960 100644 --- a/feature/download/impl/src/main/res/values/strings.xml +++ b/feature/download/impl/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + 다운로드 재시도 이력서 로드에 실패했습니다.\n다시 시도해주세요. diff --git a/feature/home/impl/src/main/java/com/nonggle/feature/home/impl/HomeScreen.kt b/feature/home/impl/src/main/java/com/nonggle/feature/home/impl/HomeScreen.kt index c3a66e8..4b74f27 100644 --- a/feature/home/impl/src/main/java/com/nonggle/feature/home/impl/HomeScreen.kt +++ b/feature/home/impl/src/main/java/com/nonggle/feature/home/impl/HomeScreen.kt @@ -81,7 +81,7 @@ internal fun HomeScreen( Column( modifier = Modifier.fillMaxWidth() ) { - NonggleMainTopAppBar() + NonggleMainTopAppBar(appBarTitle = stringResource(R.string.HomeScreen_Title)) Text( modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), text = stringResource(R.string.HomeScreen_MainTitle, uiState.userName), diff --git a/feature/home/impl/src/main/res/values/strings.xml b/feature/home/impl/src/main/res/values/strings.xml index 54eda6d..169865f 100644 --- a/feature/home/impl/src/main/res/values/strings.xml +++ b/feature/home/impl/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ - %1$s님\n이력서를\n농글과 함께 작성해보아요! - 다운로드 중인 문서가 없습니다! + 농글 + 이력서를\n농글과 함께 작성해보아요! + 자주 사용하는 이력서를 추가해보세요! 이력서 작성 \ No newline at end of file diff --git a/feature/login/impl/src/main/java/com/nonggle/impl/KakaoLoginManager.kt b/feature/login/impl/src/main/java/com/nonggle/impl/KakaoLoginManager.kt index 69100fe..2379588 100644 --- a/feature/login/impl/src/main/java/com/nonggle/impl/KakaoLoginManager.kt +++ b/feature/login/impl/src/main/java/com/nonggle/impl/KakaoLoginManager.kt @@ -22,7 +22,6 @@ class KakaoLoginManager @Inject constructor( loginWithKakaoTalk() } catch (e: Throwable) { if (e is ClientError && e.reason == ClientErrorCause.Cancelled) { - /// FIXME: 사용자가 취소했다는 다이얼로그로 처리되도록 추후 수정 throw e } else { loginWithKakaoAccount() @@ -53,5 +52,17 @@ class KakaoLoginManager @Inject constructor( } } } -} + suspend fun kakaoLogout(): Result { + return runCatching { + suspendCoroutine { continuation -> + UserApiClient.instance.logout { error -> + when { + error != null -> continuation.resumeWithException(error) + else -> continuation.resume(Unit) + } + } + } + } + } +} diff --git a/feature/setting/.gitignore b/feature/mypage/api/.gitignore similarity index 100% rename from feature/setting/.gitignore rename to feature/mypage/api/.gitignore diff --git a/feature/mypage/api/build.gradle.kts b/feature/mypage/api/build.gradle.kts new file mode 100644 index 0000000..71512df --- /dev/null +++ b/feature/mypage/api/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.nonggle.android.library) + alias(libs.plugins.nonggle.android.serialization) + alias(libs.plugins.nonggle.android.detekt) +} + +android { + namespace = "com.nonggle.mypage.api" +} + +dependencies { + implementation(libs.navigation3.runtime) +} \ No newline at end of file diff --git a/feature/setting/consumer-rules.pro b/feature/mypage/api/consumer-rules.pro similarity index 100% rename from feature/setting/consumer-rules.pro rename to feature/mypage/api/consumer-rules.pro diff --git a/feature/setting/proguard-rules.pro b/feature/mypage/api/proguard-rules.pro similarity index 100% rename from feature/setting/proguard-rules.pro rename to feature/mypage/api/proguard-rules.pro diff --git a/feature/setting/src/main/AndroidManifest.xml b/feature/mypage/api/src/main/AndroidManifest.xml similarity index 100% rename from feature/setting/src/main/AndroidManifest.xml rename to feature/mypage/api/src/main/AndroidManifest.xml diff --git a/feature/setting/src/main/java/com/nonggle/setting/navigation/SettingNavKey.kt b/feature/mypage/api/src/main/java/com/nonggle/mypage/api/MyPageNavKey.kt similarity index 58% rename from feature/setting/src/main/java/com/nonggle/setting/navigation/SettingNavKey.kt rename to feature/mypage/api/src/main/java/com/nonggle/mypage/api/MyPageNavKey.kt index 441b21e..2f2364a 100644 --- a/feature/setting/src/main/java/com/nonggle/setting/navigation/SettingNavKey.kt +++ b/feature/mypage/api/src/main/java/com/nonggle/mypage/api/MyPageNavKey.kt @@ -1,7 +1,7 @@ -package com.nonggle.setting.navigation +package com.nonggle.mypage.api import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -data object SettingNavKey: NavKey \ No newline at end of file +data object MyPageNavKey: NavKey \ No newline at end of file diff --git a/feature/mypage/impl/.gitignore b/feature/mypage/impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/mypage/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/mypage/impl/build.gradle.kts b/feature/mypage/impl/build.gradle.kts new file mode 100644 index 0000000..f3f2f2d --- /dev/null +++ b/feature/mypage/impl/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.nonggle.android.feature.ui) + alias(libs.plugins.nonggle.android.serialization) + alias(libs.plugins.nonggle.android.detekt) +} + +android { + namespace = "com.nonggle.mypage.impl" +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + implementation(project(":core:designsystem")) + implementation(project(":core:ui")) + implementation(project(":core:domain")) + implementation(project(":core:common")) + implementation(project(":feature:mypage:api")) + implementation(project(":feature:login:impl")) + implementation(project(":core:model")) + +} diff --git a/feature/mypage/impl/consumer-rules.pro b/feature/mypage/impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/mypage/impl/proguard-rules.pro b/feature/mypage/impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/mypage/impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/setting/src/androidTest/java/com/nonggle/setting/ExampleInstrumentedTest.kt b/feature/mypage/impl/src/androidTest/java/com/nonggle/mypage/impl/ExampleInstrumentedTest.kt similarity index 83% rename from feature/setting/src/androidTest/java/com/nonggle/setting/ExampleInstrumentedTest.kt rename to feature/mypage/impl/src/androidTest/java/com/nonggle/mypage/impl/ExampleInstrumentedTest.kt index 1957cd6..9a63a9b 100644 --- a/feature/setting/src/androidTest/java/com/nonggle/setting/ExampleInstrumentedTest.kt +++ b/feature/mypage/impl/src/androidTest/java/com/nonggle/mypage/impl/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.nonggle.setting +package com.nonggle.mypage.impl import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.nonggle.setting.test", appContext.packageName) + assertEquals("com.nonggle.mypage.impl.test", appContext.packageName) } } \ No newline at end of file diff --git a/feature/mypage/impl/src/main/AndroidManifest.xml b/feature/mypage/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/mypage/impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageContract.kt b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageContract.kt new file mode 100644 index 0000000..7b4369e --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageContract.kt @@ -0,0 +1,17 @@ +package com.nonggle.mypage.impl + +import com.nonggle.ui.UiEffect +import com.nonggle.ui.UiEvent +import com.nonggle.ui.UiState + +data class MyPageState( + val isLoading: Boolean = false +): UiState + +sealed interface MyPageEvent: UiEvent { + data object LogoutClicked : MyPageEvent +} + +sealed interface MyPageEffect: UiEffect { + data object LogoutFailed : MyPageEffect +} diff --git a/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageScreen.kt b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageScreen.kt new file mode 100644 index 0000000..fab45f8 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageScreen.kt @@ -0,0 +1,93 @@ +package com.nonggle.mypage.impl + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nonggle.designsystem.component.FullButton +import com.nonggle.designsystem.component.NonggleDialog +import com.nonggle.designsystem.component.NonggleMainTopAppBar +import com.nonggle.designsystem.theme.NonggleTheme + +@Composable +internal fun MyPageScreen( + modifier: Modifier = Modifier, + viewModel: MyPageViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + MyPageScreen( + modifier = modifier, + uiState = uiState, + onEvent = viewModel::setEvent, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MyPageScreen( + modifier: Modifier = Modifier, + uiState: MyPageState, + onEvent: (MyPageEvent) -> Unit = {} +) { + var showLogoutDialog by remember { mutableStateOf(false) } + if(showLogoutDialog) { + logoutDialog( + onDismiss = { showLogoutDialog = false }, + onConfirm = { + showLogoutDialog = false + onEvent(MyPageEvent.LogoutClicked) + } + ) + } + + Column( + modifier = modifier.fillMaxSize() + ) { + NonggleMainTopAppBar(appBarTitle = stringResource(R.string.MyPageScreen_MainTitle)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + verticalArrangement = Arrangement.Top + ) { + FullButton( + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading, + onClick = { showLogoutDialog = true }, + title = stringResource(R.string.MyPageScreen_LogoutButton) + ) + } + } +} + +@Composable +fun logoutDialog( + onDismiss: () -> Unit = {}, + onConfirm: () -> Unit = {}, +) { + NonggleDialog( + onDismiss = onDismiss, + onConfirm = onConfirm, + dialogTitle = stringResource(R.string.Logout_DialogTitle), + dialogContent = { + Text( + text = stringResource(R.string.Logout_DialogSubTitle), + style = NonggleTheme.typography.b3_small.copy(color = NonggleTheme.colorScheme.g2) + ) + } + ) +} diff --git a/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageViewModel.kt b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageViewModel.kt new file mode 100644 index 0000000..146140a --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/MyPageViewModel.kt @@ -0,0 +1,43 @@ +package com.nonggle.mypage.impl + +import androidx.lifecycle.viewModelScope +import com.nonggle.common.result.AuthEvent +import com.nonggle.common.result.AuthEventBus +import com.nonggle.model.AppResult +import com.nonggle.domain.usecase.LogoutUseCase +import com.nonggle.impl.KakaoLoginManager +import com.nonggle.ui.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MyPageViewModel @Inject constructor( + private val logoutUseCase: LogoutUseCase, + private val kakaoLoginManager: KakaoLoginManager, + private val authEventBus: AuthEventBus, +): BaseViewModel(initialState = MyPageState()) { + + override fun onEvent(event: MyPageEvent) { + when(event) { + MyPageEvent.LogoutClicked -> logout() + } + } + + private fun logout() { + if (currentState.isLoading) return + + viewModelScope.launch { + updateState { copy(isLoading = true) } + val apiResult = logoutUseCase() + val kakaoResult = kakaoLoginManager.kakaoLogout() + + if (apiResult is AppResult.Error || kakaoResult.isFailure) { + postEffect(MyPageEffect.LogoutFailed) + } + + authEventBus.emit(AuthEvent.LoggedOut) + updateState { copy(isLoading = false) } + } + } +} diff --git a/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/navigation/MyPageEntryProvider.kt b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/navigation/MyPageEntryProvider.kt new file mode 100644 index 0000000..f0c80dc --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/nonggle/mypage/impl/navigation/MyPageEntryProvider.kt @@ -0,0 +1,13 @@ +package com.nonggle.mypage.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.nonggle.mypage.api.MyPageNavKey +import com.nonggle.mypage.impl.MyPageScreen +import com.nonggle.navigation.Navigator + +fun EntryProviderScope.myPageEntryProvider(navigator: Navigator) { + entry { + MyPageScreen() + } +} \ No newline at end of file diff --git a/feature/mypage/impl/src/main/res/values/strings.xml b/feature/mypage/impl/src/main/res/values/strings.xml new file mode 100644 index 0000000..618080b --- /dev/null +++ b/feature/mypage/impl/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 마이페이지 + 로그아웃 + 로그아웃 + 로그아웃 하시겠습니까? + diff --git a/feature/setting/src/test/java/com/nonggle/setting/ExampleUnitTest.kt b/feature/mypage/impl/src/test/java/com/nonggle/mypage/impl/ExampleUnitTest.kt similarity index 90% rename from feature/setting/src/test/java/com/nonggle/setting/ExampleUnitTest.kt rename to feature/mypage/impl/src/test/java/com/nonggle/mypage/impl/ExampleUnitTest.kt index b1377e8..3c29218 100644 --- a/feature/setting/src/test/java/com/nonggle/setting/ExampleUnitTest.kt +++ b/feature/mypage/impl/src/test/java/com/nonggle/mypage/impl/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.nonggle.setting +package com.nonggle.mypage.impl import org.junit.Test diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/component/ResumeStep1Component.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/component/ResumeStep1Component.kt index 975e3d8..338157b 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/component/ResumeStep1Component.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/component/ResumeStep1Component.kt @@ -188,8 +188,9 @@ fun certificationInput( } if (certificationList.isNotEmpty()) { FlowRow( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { certificationList.forEach { item -> NonggleChip( diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainContract.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainContract.kt index 7fc3a79..5361b2a 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainContract.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainContract.kt @@ -5,7 +5,7 @@ import com.nonggle.ui.UiEvent import com.nonggle.ui.UiState import com.nonggle.feature.resume.impl.R -// 각 탭을 명확하게 식별하기 위한 Enum 클래스 +// 각 탭을 식별하기 위한 Enum 클래스 enum class ResumeTab(val value: Int) { INFO(0), // 기본 정보 CAREER(1), // 경력 @@ -19,18 +19,18 @@ enum class ResumeTab(val value: Int) { } } -// 이력서 화면의 전체적인 UI 상태 (훨씬 단순해짐) +// 이력서 화면의 전체적인 UI 상태 data class ResumeMainState( - val isLoading: Boolean = false, val selectedTab: ResumeTab = ResumeTab.INFO, - val tabList: List = listOf(R.string.resume_basicTitle, R.string.resume_careerTitle, R.string.resume_portfolioTitle) + val tabList: List = listOf(R.string.resume_basicTitle, R.string.resume_careerTitle, R.string.resume_portfolioTitle), + val submitStatus: Boolean = false ) : UiState sealed interface ResumeMainEvent: UiEvent { - data object submitResume: ResumeMainEvent + data object NavigateToComplete : ResumeMainEvent } sealed interface ResumeMainEffect: UiEffect { - data class showErrorToastMessage(val message: String): ResumeMainEffect + data object NavigateToComplete: ResumeMainEffect } diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainScreen.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainScreen.kt index dcc48f7..7b84ad3 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainScreen.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainScreen.kt @@ -1,6 +1,5 @@ package com.nonggle.resume.impl.main -import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,11 +9,11 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.nonggle.designsystem.component.NonggleTabRow import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -41,7 +40,14 @@ internal fun ResumeMainScreen( val pagerState = rememberPagerState(pageCount = { uiState.tabList.size }) val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current + + LaunchedEffect(viewModel.effect) { + viewModel.effect.collect { effect -> + when(effect) { + is ResumeMainEffect.NavigateToComplete -> navigateToComplete() + } + } + } ResumeMainScreen( modifier = modifier, @@ -52,10 +58,9 @@ internal fun ResumeMainScreen( pagerState.animateScrollToPage(index) } }, - navigateToComplete = { + navigateToNext = { if (pagerState.currentPage == uiState.tabList.size - 1) { - /// FIXME: 데이터 검증 거친 후 이동 만약 검증 실패시 오류 생긴 화면으로 이동 - navigateToComplete() + viewModel.setEvent(ResumeMainEvent.NavigateToComplete) } else { coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) @@ -63,7 +68,7 @@ internal fun ResumeMainScreen( } }, navigateGoBack = navigateToHome, - context = context + submitState = uiState.submitStatus, ) } @@ -73,10 +78,10 @@ internal fun ResumeMainScreen( modifier: Modifier = Modifier, tabList: List, pagerState: PagerState, + submitState: Boolean, onTabClick: (Int) -> Unit, - navigateToComplete: () -> Unit, + navigateToNext: () -> Unit, navigateGoBack: () -> Unit, - context: Context, ) { Column( @@ -117,10 +122,9 @@ internal fun ResumeMainScreen( FullButton( modifier = Modifier .fillMaxWidth(), - onClick = navigateToComplete, - title = if (pagerState.currentPage == tabList.size - 1) stringResource(R.string.resume_complete) else stringResource( - R.string.resume_nextStep - ) + enabled = if (pagerState.currentPage == tabList.size - 1) submitState else true, + onClick = navigateToNext, + title = if (pagerState.currentPage == tabList.size - 1) stringResource(R.string.resume_complete) else stringResource(R.string.resume_nextStep) ) } diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainViewModel.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainViewModel.kt index 2cadb67..0eb0ab5 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainViewModel.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/main/ResumeMainViewModel.kt @@ -1,21 +1,51 @@ package com.nonggle.resume.impl.main +import androidx.lifecycle.viewModelScope import com.nonggle.ui.BaseViewModel import com.nonggle.domain.repository.ResumeDraftStoreInterface +import com.nonggle.model.ResumeWritingModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ResumeMainViewModel @Inject constructor( private val resumeStore: ResumeDraftStoreInterface, ) : BaseViewModel( - initialState = ResumeMainState() - ) { + initialState = ResumeMainState() +) { + init { + viewModelScope.launch { + resumeStore.draft.collectLatest { resume -> + updateState { copy(submitStatus = validateResume(resume)) } + } + } + } override fun onEvent(event: ResumeMainEvent) { - when(event) { - - else -> {} + when (event) { + is ResumeMainEvent.NavigateToComplete -> { + if (currentState.submitStatus) { + postEffect(ResumeMainEffect.NavigateToComplete) + } + } } } -} \ No newline at end of file + + private fun validateResume(resume: ResumeWritingModel): Boolean { + val isNameValid = resume.userName.isNotEmpty() + val isBirthDateValid = resume.birthDate.isNotEmpty() + val isGenderValid = resume.gender.isNotEmpty() + val isIntroduceDetailValid = resume.introduceDetail.isNotEmpty() + val isPersonalityListValid = resume.personalityList.isNotEmpty() + val isIntroduceValid = resume.introduce.isNotEmpty() + + return isNameValid && + isBirthDateValid && + isGenderValid && + isIntroduceValid && + isIntroduceDetailValid && + isPersonalityListValid + } +} diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Contract.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Contract.kt index 31737fe..9858469 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Contract.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Contract.kt @@ -43,7 +43,7 @@ data class ResumeStep1State( sealed interface ResumeStep1Event : UiEvent { data class SelectImage(val imageUri: Uri?): ResumeStep1Event - data class ImageVolumeExceeded(val message: String): ResumeStep1Event + data object ImageVolumeExceeded: ResumeStep1Event data class UserNameChanged(val userName: String): ResumeStep1Event data object UserNameCleared: ResumeStep1Event @@ -63,5 +63,6 @@ sealed interface ResumeStep1Event : UiEvent { } sealed interface ResumeStep1Effect : UiEffect { - data class SendToastMessage(val message: String): ResumeStep1Effect + data object SendImageVolumeOverFlowMessage: ResumeStep1Effect + data object SendBirthDateNotValidMessage: ResumeStep1Effect } \ No newline at end of file diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Screen.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Screen.kt index 6a78bec..129ca4b 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Screen.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1Screen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight @@ -63,6 +64,9 @@ internal fun ResumeStep1Screen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val profileImageOverflowMessage = stringResource(R.string.resume1Screen_profile_image_overflow) + val birthDateNotValidMessage = stringResource(R.string.resume1Screen_birthdate_notValid) + val scrollState = rememberScrollState() val datePickerState = rememberDatePickerState() @@ -78,8 +82,11 @@ internal fun ResumeStep1Screen( LaunchedEffect(viewModel.effect) { viewModel.effect.collect { effect -> when (effect) { - is ResumeStep1Effect.SendToastMessage -> { - Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + is ResumeStep1Effect.SendImageVolumeOverFlowMessage -> { + Toast.makeText(context, profileImageOverflowMessage, Toast.LENGTH_SHORT).show() + } + is ResumeStep1Effect.SendBirthDateNotValidMessage -> { + Toast.makeText(context, birthDateNotValidMessage, Toast.LENGTH_SHORT).show() } } } @@ -92,7 +99,7 @@ internal fun ResumeStep1Screen( val sizeInBytes = getImageSizeFromUri(context, uri) val sizeInMB = sizeInBytes / (1024.0 * 1024.0) if (sizeInMB > Policy.MAX_PROFILE_IMAGE_SIZE_IN_BYTES) { - viewModel.setEvent(ResumeStep1Event.ImageVolumeExceeded(message = "업로드 가능한 이미지 용량을 초과했습니다.")) + viewModel.setEvent(ResumeStep1Event.ImageVolumeExceeded) return@rememberLauncherForActivityResult } else { uri.let { viewModel.setEvent(ResumeStep1Event.SelectImage(it)) } @@ -141,6 +148,7 @@ internal fun ResumeStep1Screen( .fillMaxSize() .padding(horizontal = 20.dp, vertical = 24.dp) .verticalScroll(scrollState) + .imePadding() ) { Text( text = stringResource(R.string.resume1Screen_profile_image), diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1ViewModel.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1ViewModel.kt index b7883f4..60210d3 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1ViewModel.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step1/ResumeStep1ViewModel.kt @@ -20,50 +20,44 @@ class ResumeStep1ViewModel @Inject constructor( private val imageContentReadUseCase: ImageContentReadUseCase ) : BaseViewModel(initialState = ResumeStep1State()) { override fun onEvent(event: ResumeStep1Event) { - when (event) { - is ResumeStep1Event.SelectImage -> setProfileImageUri(event.imageUri) - - is ResumeStep1Event.ImageVolumeExceeded -> { - postEffect(ResumeStep1Effect.SendToastMessage(message = event.message)) + viewModelScope.launch { + when (event) { + is ResumeStep1Event.SelectImage -> setProfileImageUri(event.imageUri) + + is ResumeStep1Event.ImageVolumeExceeded -> { + postEffect(ResumeStep1Effect.SendImageVolumeOverFlowMessage) + } + + is ResumeStep1Event.UserNameChanged -> userNameChanged(event.userName) + is ResumeStep1Event.UserNameCleared -> userNameCleared() + is ResumeStep1Event.RemoveProfileImage -> removeProfileImageUri() + + is ResumeStep1Event.BirthDateChanged -> setBirthDate(event.birthDate) + is ResumeStep1Event.SelectGender -> selectGender(event.gender) + is ResumeStep1Event.ExistCertification -> existCertification(event.exist) + is ResumeStep1Event.AddCertification -> addCertification() + is ResumeStep1Event.CertificationChanged -> updateState { copy(certificationInput = event.certification) } + is ResumeStep1Event.RemoveCertificationChip -> removeCertificationChip(event.id) } - - is ResumeStep1Event.UserNameChanged -> userNameChanged(event.userName) - is ResumeStep1Event.UserNameCleared -> userNameCleared() - is ResumeStep1Event.RemoveProfileImage -> removeProfileImageUri() - - is ResumeStep1Event.BirthDateChanged -> setBirthDate(event.birthDate) - is ResumeStep1Event.SelectGender -> selectGender(event.gender) - is ResumeStep1Event.ExistCertification -> existCertification(event.exist) - is ResumeStep1Event.AddCertification -> addCertification() - is ResumeStep1Event.CertificationChanged -> updateState { copy(certificationInput = event.certification) } - is ResumeStep1Event.RemoveCertificationChip -> removeCertificationChip(event.id) } } - private fun setProfileImageUri(imageUri: Uri?) { - if(imageUri == null) { - return - } + private suspend fun setProfileImageUri(imageUri: Uri?) { + if (imageUri == null) return var imageMeta: ResumeWritingModel.ResumeImageMeta? = null - viewModelScope.launch { - yield() - imageMeta = imageContentReadUseCase(imageUri.toString()) + imageMeta = imageContentReadUseCase(imageUri.toString()) - if (imageMeta == null) { - // TODO 토스트/에러 상태 - return@launch - } + if (imageMeta == null) return - updateState { - copy( - info = this.info.copy(profileImageUrl = imageUri.toString()) - ) - } - resumeStore.update { it.copy(imageMeta = imageMeta!!) } + updateState { + copy( + info = this.info.copy(profileImageUrl = imageUri.toString()) + ) } + resumeStore.update { it.copy(imageMeta = imageMeta) } } - private fun removeProfileImageUri() { + private suspend fun removeProfileImageUri() { updateState { copy( info = this.info.copy( @@ -76,21 +70,21 @@ class ResumeStep1ViewModel @Inject constructor( } } - private fun userNameChanged(userName: String) { + private suspend fun userNameChanged(userName: String) { updateState { copy(info = this.info.copy(userName = userName)) } resumeStore.update { it.copy(userName = userName) } } - private fun userNameCleared() { + private suspend fun userNameCleared() { updateState { copy(info = this.info.copy(userName = "")) } resumeStore.update { it.copy(userName = "") } } - private fun selectGender(gender: Gender) { + private suspend fun selectGender(gender: Gender) { updateState { copy(info = this.info.copy(gender = gender)) } resumeStore.update { it.copy(gender = gender.value) @@ -98,7 +92,12 @@ class ResumeStep1ViewModel @Inject constructor( } - private fun setBirthDate(date: LocalDate) { + private suspend fun setBirthDate(date: LocalDate) { + // 생년월일 지정 날짜가 현재를 초과할 수 없으므로 에러 토스트 발행 + if (date.isAfter(LocalDate.now())) { + postEffect(ResumeStep1Effect.SendBirthDateNotValidMessage) + return + } updateState { copy( info = this.info.copy( @@ -115,7 +114,7 @@ class ResumeStep1ViewModel @Inject constructor( } } - private fun existCertification(exist: Boolean) { + private suspend fun existCertification(exist: Boolean) { if (exist) { updateState { copy(certificationExist = exist) @@ -133,7 +132,7 @@ class ResumeStep1ViewModel @Inject constructor( } } - private fun removeCertificationChip(id: String) { + private suspend fun removeCertificationChip(id: String) { updateState { val certificationList = this.info.certificationList.filter { it.id != id } copy(info = this.info.copy(certificationList = certificationList)) @@ -143,7 +142,7 @@ class ResumeStep1ViewModel @Inject constructor( } } - private fun addCertification() { + private suspend fun addCertification() { val newCertificationList = currentState.info.certificationList + CertificationTag(certificationTitle = currentState.certificationInput.trim()) updateState { diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Contract.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Contract.kt index ac9b6e2..97a193c 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Contract.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Contract.kt @@ -1,6 +1,7 @@ package com.nonggle.resume.impl.step2 import androidx.compose.runtime.Stable +import com.nonggle.resume.impl.step1.ResumeStep1Effect import com.nonggle.ui.UiEffect import com.nonggle.ui.UiEvent import com.nonggle.ui.UiState @@ -42,5 +43,5 @@ sealed interface ResumeStep2Event : UiEvent { } sealed interface ResumeStep2Effect : UiEffect { - + data object SendStartCareerNotValidMessage: ResumeStep2Effect } \ No newline at end of file diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Screen.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Screen.kt index 76556d7..39b2292 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Screen.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2Screen.kt @@ -1,5 +1,6 @@ package com.nonggle.resume.impl.step2 +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -11,11 +12,13 @@ import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import com.nonggle.designsystem.component.OutlinedButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -43,6 +46,18 @@ internal fun ResumeStep2Screen( skipPartiallyExpanded = true, confirmValueChange = {false} ) + val startCareerDateNotValidMessage = stringResource(R.string.resume2Screen_startCareerDate_NotValid) + val context = LocalContext.current + + LaunchedEffect(viewModel.effect) { + viewModel.effect.collect { effect -> + when(effect) { + is ResumeStep2Effect.SendStartCareerNotValidMessage -> { + Toast.makeText(context, startCareerDateNotValidMessage, Toast.LENGTH_SHORT).show() + } + } + } + } ResumeStep2Screen( modifier = modifier, @@ -110,8 +125,19 @@ internal fun ResumeStep2Screen( onClick = {}, // do nothing titleText = getPeriodFormatter(uiState.totalCareer) ) + OutlinedIconButton( + contentColor = NonggleTheme.colorScheme.g3, + disableContentColor = NonggleTheme.colorScheme.g3, + borderColor = NonggleTheme.colorScheme.g_line, + titleText = stringResource(R.string.resume2Screen_Title_careerAddTitle), + titleTextStyle = NonggleTheme.typography.b4_btn.copy(color = NonggleTheme.colorScheme.g3), + onClick = careerBottomSheetClick + ) LazyColumn( - modifier = Modifier.padding(bottom = 16.dp), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(bottom = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( @@ -128,13 +154,5 @@ internal fun ResumeStep2Screen( } ) } - OutlinedIconButton( - contentColor = NonggleTheme.colorScheme.g3, - disableContentColor = NonggleTheme.colorScheme.g3, - borderColor = NonggleTheme.colorScheme.g_line, - titleText = stringResource(R.string.resume2Screen_Title_careerAddTitle), - titleTextStyle = NonggleTheme.typography.b4_btn.copy(color = NonggleTheme.colorScheme.g3), - onClick = careerBottomSheetClick - ) } -} \ No newline at end of file +} diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2ViewModel.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2ViewModel.kt index b456d7c..ba6bfa6 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2ViewModel.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step2/ResumeStep2ViewModel.kt @@ -1,10 +1,13 @@ package com.nonggle.resume.impl.step2 +import androidx.lifecycle.viewModelScope import com.nonggle.common.utils.getPeriodFormatter import com.nonggle.ui.BaseViewModel import com.nonggle.domain.repository.ResumeDraftStoreInterface import com.nonggle.model.ResumeWritingModel +import com.nonggle.resume.impl.step1.ResumeStep1Effect import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.Period import javax.inject.Inject @@ -15,17 +18,21 @@ class ResumeStep2ViewModel @Inject constructor( ) : BaseViewModel(initialState = ResumeStep2State()) { override fun onEvent(event: ResumeStep2Event) { - when (event) { - is ResumeStep2Event.CareerSheetEvent -> handleCareerSheetEvent(event.event) - is ResumeStep2Event.DeleteCareerItem -> deleteCareerItem(event.id) + viewModelScope.launch { + when (event) { + is ResumeStep2Event.CareerSheetEvent -> handleCareerSheetEvent(event.event) + is ResumeStep2Event.DeleteCareerItem -> deleteCareerItem(event.id) + } } } - private fun handleCareerSheetEvent(careerSheetEvent: CareerBottomSheetEvent) { + private suspend fun handleCareerSheetEvent(careerSheetEvent: CareerBottomSheetEvent) { when (careerSheetEvent) { // 근무 시작일 선택 is CareerBottomSheetEvent.SelectCareerStartDate -> { - updateState { copy(careerFormData = this.careerFormData.copy(careerStartDate = careerSheetEvent.date)) } + if(validateStartDate(careerSheetEvent.date)) { + updateState { copy(careerFormData = this.careerFormData.copy(careerStartDate = careerSheetEvent.date)) } + } } // 근무 종료일 선택 @@ -54,12 +61,34 @@ class ResumeStep2ViewModel @Inject constructor( } } - private fun sumCareerPeriod(items: List): Period = - items.fold(Period.ZERO) { acc, item -> - acc.plus(Period.between(item.careerStartDate, item.careerEndDate)) + private fun validateStartDate(date: LocalDate): Boolean { + if (date.isAfter(LocalDate.now())) { + postEffect(ResumeStep2Effect.SendStartCareerNotValidMessage) + return false + } + return true + } + + private fun sumCareerPeriod(items: List): Period { + val totalMonths = items.sumOf { item -> + val period = Period.between(item.careerStartDate, item.careerEndDate) + period.years * 12 + period.months } + val totalDays = items.sumOf { item -> + Period.between(item.careerStartDate, item.careerEndDate).days + } + + val normalizedMonths = totalMonths + (totalDays / 30) + val normalizedDays = totalDays % 30 + + return Period.of( + normalizedMonths / 12, + normalizedMonths % 12, + normalizedDays + ) + } - private fun addCareerItem(data: CareerFormData) { + private suspend fun addCareerItem(data: CareerFormData) { val newCareerList = currentState.careerList + data updateState { copy( @@ -71,7 +100,7 @@ class ResumeStep2ViewModel @Inject constructor( saveTempResume(newCareerList) } - private fun deleteCareerItem(id: String) { + private suspend fun deleteCareerItem(id: String) { val newCareerList = currentState.careerList.filter { it.id != id } updateState { copy( @@ -82,7 +111,8 @@ class ResumeStep2ViewModel @Inject constructor( deleteTempResume(newCareerList) } - private fun saveTempResume(newCareerList: List) { + private suspend fun saveTempResume(newCareerList: List) { + val totalCareer = sumCareerPeriod(newCareerList) resumeStore.update { it.copy( careerList = newCareerList.map { @@ -99,14 +129,13 @@ class ResumeStep2ViewModel @Inject constructor( careerDetail = it.careerDetail ) }, - totalCareer = getPeriodFormatter(newCareerList.map { - Period.between(it.careerStartDate, it.careerEndDate) - }.reduce { acc, period -> acc.plus(period) }) + totalCareer = getPeriodFormatter(totalCareer) ) } } - private fun deleteTempResume(newCareerList: List) { + private suspend fun deleteTempResume(newCareerList: List) { + val totalCareer = sumCareerPeriod(newCareerList) resumeStore.update { it.copy( careerList = newCareerList.map { @@ -123,10 +152,8 @@ class ResumeStep2ViewModel @Inject constructor( it.careerDetail ) }, - totalCareer = getPeriodFormatter(newCareerList.map { - Period.between(it.careerStartDate, it.careerEndDate) - }.reduce { acc, period -> acc.plus(period) }) + totalCareer = getPeriodFormatter(totalCareer) ) } } -} \ No newline at end of file +} diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3Screen.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3Screen.kt index 4341098..01998c0 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3Screen.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3Screen.kt @@ -1,5 +1,6 @@ package com.nonggle.resume.impl.step3 +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow @@ -7,8 +8,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,11 +37,13 @@ internal fun ResumeStep3Screen( viewModel: ResumeStep3ViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val scrollState = rememberScrollState() ResumeStep3Screen( modifier = modifier, uiState = uiState, - onEvent = viewModel::setEvent + onEvent = viewModel::setEvent, + scrollState = scrollState ) } @@ -46,11 +52,14 @@ internal fun ResumeStep3Screen( modifier: Modifier = Modifier, uiState: ResumeStep3State, onEvent: (ResumeStep3Event) -> Unit = {}, -) { + scrollState: ScrollState, + ) { Column( modifier = modifier .fillMaxSize() .padding(horizontal = 20.dp) + .verticalScroll(scrollState) + .imePadding() ) { NonggleTextField( modifier = Modifier @@ -118,7 +127,8 @@ internal fun ResumeStep3Screen( if(uiState.personalityList.isNotEmpty()) { FlowRow( modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceAround, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { uiState.personalityList.forEach { item -> NonggleChip( diff --git a/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3ViewModel.kt b/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3ViewModel.kt index 1f38781..6f58aaa 100644 --- a/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3ViewModel.kt +++ b/feature/resume/impl/src/main/java/com/nonggle/impl/step3/ResumeStep3ViewModel.kt @@ -1,8 +1,10 @@ package com.nonggle.resume.impl.step3 +import androidx.lifecycle.viewModelScope import com.nonggle.ui.BaseViewModel import com.nonggle.domain.repository.ResumeDraftStoreInterface import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -11,23 +13,25 @@ class ResumeStep3ViewModel @Inject constructor( ) : BaseViewModel(initialState = ResumeStep3State()) { override fun onEvent(event: ResumeStep3Event) { - when(event) { - is ResumeStep3Event.IntroduceChanged -> introduceChanged(event.introduce) - is ResumeStep3Event.PersonalityInput -> updateState { copy(personality = event.value) } - is ResumeStep3Event.RemovePersonalityChip -> removePersonalityChip(event.id) - is ResumeStep3Event.AddPersonalityChip -> addPersonality() - is ResumeStep3Event.IntroduceDetailInput -> introduceDetailChanged(event.value) + viewModelScope.launch { + when(event) { + is ResumeStep3Event.IntroduceChanged -> introduceChanged(event.introduce) + is ResumeStep3Event.PersonalityInput -> updateState { copy(personality = event.value) } + is ResumeStep3Event.RemovePersonalityChip -> removePersonalityChip(event.id) + is ResumeStep3Event.AddPersonalityChip -> addPersonality() + is ResumeStep3Event.IntroduceDetailInput -> introduceDetailChanged(event.value) + } } } - private fun introduceChanged(introduce: String) { + private suspend fun introduceChanged(introduce: String) { updateState { copy(introduce = introduce) } resumeStore.update { it.copy(introduce = introduce) } } - private fun removePersonalityChip(chipId: String) { + private suspend fun removePersonalityChip(chipId: String) { val newList = currentState.personalityList.filter { it.id != chipId } updateState { copy(personalityList = newList) } resumeStore.update { @@ -35,7 +39,7 @@ class ResumeStep3ViewModel @Inject constructor( } } - private fun addPersonality() { + private suspend fun addPersonality() { val newList = currentState.personalityList + PersonalityTag(personality = currentState.personality?.trim() ?: "") updateState { if ((personality ?: "").trim().isEmpty()) return@updateState this @@ -49,7 +53,7 @@ class ResumeStep3ViewModel @Inject constructor( } } - private fun introduceDetailChanged(value: String) { + private suspend fun introduceDetailChanged(value: String) { updateState { copy(introduceDetail = value) } resumeStore.update { it.copy(introduceDetail = value) diff --git a/feature/resume/impl/src/main/res/values/strings.xml b/feature/resume/impl/src/main/res/values/strings.xml index 1bdf669..44767f2 100644 --- a/feature/resume/impl/src/main/res/values/strings.xml +++ b/feature/resume/impl/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ 프로필 이미지 업로드 가능한 이미지 용량을 초과했습니다. + 현재 날짜를 초과하는 생년월일을 지정할 수 없습니다. 본인을 소개할 수 있는 이미지를 업로드 해주세요. 이름 이름은 필수 입력값입니다. @@ -39,6 +40,7 @@ 작업기간 근무시작일 근무종료일 + 근무 시작날짜는 현재 날짜를 초과할 수 없습니다. 년 월 선택 diff --git a/feature/resume/impl/src/test/java/com/nonggle/impl/step2/ResumeStep2ViewModelTest.kt b/feature/resume/impl/src/test/java/com/nonggle/impl/step2/ResumeStep2ViewModelTest.kt index dc25d17..24e1838 100644 --- a/feature/resume/impl/src/test/java/com/nonggle/impl/step2/ResumeStep2ViewModelTest.kt +++ b/feature/resume/impl/src/test/java/com/nonggle/impl/step2/ResumeStep2ViewModelTest.kt @@ -1,13 +1,18 @@ -package com.example.feature.resume.impl.step2 +package com.nonggle.impl.step2 import com.nonggle.domain.repository.ResumeDraftStoreInterface import com.nonggle.model.ResumeWritingModel +import com.nonggle.resume.impl.step2.CareerBottomSheetEvent +import com.nonggle.resume.impl.step2.CareerFormData +import com.nonggle.resume.impl.step2.ResumeStep2Event +import com.nonggle.resume.impl.step2.ResumeStep2ViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -64,6 +69,7 @@ class ResumeStep2ViewModelTest { // WHEN (실행) viewModel.setEvent(ResumeStep2Event.CareerSheetEvent(CareerBottomSheetEvent.AddCareerItem(newCareer))) + advanceUntilIdle() // THEN (검증) val currentState = viewModel.uiState.value @@ -91,9 +97,11 @@ class ResumeStep2ViewModelTest { val career2 = CareerFormData("2", LocalDate.of(2023, 1, 1), LocalDate.of(2023, 12, 31), "Career 2", "...") viewModel.setEvent(ResumeStep2Event.CareerSheetEvent(CareerBottomSheetEvent.AddCareerItem(career1))) viewModel.setEvent(ResumeStep2Event.CareerSheetEvent(CareerBottomSheetEvent.AddCareerItem(career2))) + advanceUntilIdle() // WHEN (실행): ID가 "1"인 경력을 삭제 viewModel.setEvent(ResumeStep2Event.DeleteCareerItem("1")) + advanceUntilIdle() // THEN (검증) val currentState = viewModel.uiState.value @@ -112,6 +120,35 @@ class ResumeStep2ViewModelTest { assertEquals("Career 2", storedDraft.careerList.first().careerDescription) assertEquals(expectedPeriod, storedDraft.totalCareer) } + + @Test + fun `sumCareerPeriod - 30일 이상이면 개월로 올림하여 총 경력을 계산해야 한다`() = runTest { + val career1 = CareerFormData("1", LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 15), "Career 1", "...") + val career2 = CareerFormData("2", LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 15), "Career 2", "...") + + viewModel.setEvent(ResumeStep2Event.CareerSheetEvent(CareerBottomSheetEvent.AddCareerItem(career1))) + viewModel.setEvent(ResumeStep2Event.CareerSheetEvent(CareerBottomSheetEvent.AddCareerItem(career2))) + advanceUntilIdle() + + assertEquals(Period.of(0, 3, 0), viewModel.uiState.value.totalCareer) + assertEquals(Period.of(0, 3, 0), fakeResumeStore.draft.value.totalCareer) + } + + @Test + fun `deleteCareerItem - 마지막 경력 삭제시 총 경력도 0으로 재계산되어야 한다`() = runTest { + val career = CareerFormData("1", LocalDate.of(2023, 1, 1), LocalDate.of(2023, 3, 1), "Career 1", "...") + + viewModel.setEvent(ResumeStep2Event.CareerSheetEvent(CareerBottomSheetEvent.AddCareerItem(career))) + advanceUntilIdle() + + viewModel.setEvent(ResumeStep2Event.DeleteCareerItem("1")) + advanceUntilIdle() + + assertEquals(emptyList(), viewModel.uiState.value.careerList) + assertEquals(Period.ZERO, viewModel.uiState.value.totalCareer) + assertEquals(emptyList(), fakeResumeStore.draft.value.careerList) + assertEquals(Period.ZERO, fakeResumeStore.draft.value.totalCareer) + } } /** @@ -121,12 +158,12 @@ class ResumeStep2ViewModelTest { class FakeResumeDraftStore : ResumeDraftStoreInterface { private val _draft = MutableStateFlow(ResumeWritingModel()) - val draft: StateFlow = _draft + override val draft: StateFlow = _draft - override fun update(reducer: (ResumeWritingModel) -> ResumeWritingModel) { + override suspend fun update(reducer: (ResumeWritingModel) -> ResumeWritingModel) { _draft.update(reducer) } - override fun snapshot(): ResumeWritingModel = _draft.value + override suspend fun snapshot(): ResumeWritingModel = _draft.value -} \ No newline at end of file +} diff --git a/feature/setting/build.gradle.kts b/feature/setting/build.gradle.kts deleted file mode 100644 index def17fb..0000000 --- a/feature/setting/build.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -plugins { - alias(libs.plugins.nonggle.android.feature.ui) - alias(libs.plugins.nonggle.android.serialization) - alias(libs.plugins.nonggle.android.detekt) -} - -android { - namespace = "com.nonggle.setting" -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file diff --git a/feature/setting/src/main/java/com/nonggle/setting/SettingScreen.kt b/feature/setting/src/main/java/com/nonggle/setting/SettingScreen.kt deleted file mode 100644 index d07b014..0000000 --- a/feature/setting/src/main/java/com/nonggle/setting/SettingScreen.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.nonggle.setting - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview - -@Composable -internal fun SettingScreen(onBackClick: () -> Unit) { - SettingScreen() -} - -@Composable -internal fun SettingScreen() { - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items(100) { - Text(text = "설정") - } - } - } -} - -@Composable -@Preview(showBackground = true) -fun SettingScreenPreview() { - -} \ No newline at end of file diff --git a/feature/setting/src/main/java/com/nonggle/setting/navigation/SettingEntryProvider.kt b/feature/setting/src/main/java/com/nonggle/setting/navigation/SettingEntryProvider.kt deleted file mode 100644 index 3b01454..0000000 --- a/feature/setting/src/main/java/com/nonggle/setting/navigation/SettingEntryProvider.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nonggle.setting.navigation -import com.nonggle.navigation.Navigator -import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavKey -import com.nonggle.setting.SettingScreen - -fun EntryProviderScope.settingEntryProvider(navigator: Navigator) { - entry { - SettingScreen ( - onBackClick = { navigator.goBack() }, - ) - } -} \ No newline at end of file diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml deleted file mode 100644 index 3084b68..0000000 --- a/feature/setting/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - 설정 - \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 0e33f71..2465fb8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,20 +30,21 @@ include(":core:designsystem") include(":core:navigation") include(":core:domain") include(":core:common") +include(":core:pdf_render") +include(":core:network") +include(":core:auth") +include(":core:model") +include(":core:ui") -include(":feature:setting") include(":feature:login:impl") include(":feature:login:api") -include(":core:ui") include(":feature:resume:impl") include(":feature:resume:api") include(":feature:home:api") include(":feature:home:impl") -include(":core:network") -include(":core:auth") -include(":core:model") include(":feature:resume_view:api") include(":feature:resume_view:impl") include(":feature:download:api") include(":feature:download:impl") -include(":core:pdf_render") +include(":feature:mypage:api") +include(":feature:mypage:impl")