diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c605554..0bb5117 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,9 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hilt.android) + alias(libs.plugins.ksp) } val localProperties = Properties().apply { @@ -72,6 +75,7 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) @@ -85,6 +89,12 @@ dependencies { implementation(libs.androidx.credentials.play.services.auth) implementation(libs.googleid) implementation(libs.kakao.user) + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) + ksp(libs.hilt.compiler) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSourceTest.kt b/app/src/androidTest/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSourceTest.kt new file mode 100644 index 0000000..9b6ad6d --- /dev/null +++ b/app/src/androidTest/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSourceTest.kt @@ -0,0 +1,39 @@ +package com.example.it_da.data.local + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultAuthTokenLocalDataSourceTest { + private val localDataSource = DefaultAuthTokenLocalDataSource( + InstrumentationRegistry.getInstrumentation().targetContext + ) + + @Before + fun clearTokenBeforeTest() = runBlocking { + localDataSource.clearToken() + } + + @After + fun clearTokenAfterTest() = runBlocking { + localDataSource.clearToken() + } + + @Test + fun savesLoadsAndClearsTokenInPreferencesDataStore() = runBlocking { + localDataSource.saveToken("stored-token") + + assertEquals("stored-token", localDataSource.getToken()) + + localDataSource.clearToken() + + assertNull(localDataSource.getToken()) + } +} diff --git a/app/src/main/java/com/example/it_da/ItdaApplication.kt b/app/src/main/java/com/example/it_da/ItdaApplication.kt index 51e0c50..314fc27 100644 --- a/app/src/main/java/com/example/it_da/ItdaApplication.kt +++ b/app/src/main/java/com/example/it_da/ItdaApplication.kt @@ -2,7 +2,9 @@ package com.example.it_da import android.app.Application import com.kakao.sdk.common.KakaoSdk +import dagger.hilt.android.HiltAndroidApp +@HiltAndroidApp class ItdaApplication : Application() { // Initializes Kakao SDK once when the app process starts if a native app key is configured. override fun onCreate() { diff --git a/app/src/main/java/com/example/it_da/MainActivity.kt b/app/src/main/java/com/example/it_da/MainActivity.kt index 0f88b5a..10e8d8b 100644 --- a/app/src/main/java/com/example/it_da/MainActivity.kt +++ b/app/src/main/java/com/example/it_da/MainActivity.kt @@ -6,7 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import com.example.it_da.ui.ItdaApp import com.example.it_da.ui.theme.ITDATheme +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt b/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt index 73ac990..095395f 100644 --- a/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt +++ b/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt @@ -1,10 +1,13 @@ package com.example.it_da.data.auth import com.example.it_da.domain.model.SocialAuthAccount +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -class SocialAuthSessionStore { +@Singleton +class SocialAuthSessionStore @Inject constructor() { private val _currentAccount = MutableStateFlow(null) val currentAccount = _currentAccount.asStateFlow() @@ -17,8 +20,4 @@ class SocialAuthSessionStore { fun clear() { _currentAccount.value = null } - - companion object { - val default = SocialAuthSessionStore() - } } diff --git a/app/src/main/java/com/example/it_da/data/local/AuthTokenLocalDataSource.kt b/app/src/main/java/com/example/it_da/data/local/AuthTokenLocalDataSource.kt new file mode 100644 index 0000000..757cc9a --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/local/AuthTokenLocalDataSource.kt @@ -0,0 +1,12 @@ +package com.example.it_da.data.local + +interface AuthTokenLocalDataSource { + // Loads the locally stored authentication token used by the launch session check. + suspend fun getToken(): String? + + // Persists the authentication token so the next app launch can restore the session. + suspend fun saveToken(token: String) + + // Removes the locally stored authentication token when the session should end. + suspend fun clearToken() +} diff --git a/app/src/main/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSource.kt b/app/src/main/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSource.kt new file mode 100644 index 0000000..29b1179 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSource.kt @@ -0,0 +1,44 @@ +package com.example.it_da.data.local + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +private const val AuthSessionDataStoreName = "auth_session" + +private val Context.authSessionDataStore by preferencesDataStore( + name = AuthSessionDataStoreName +) + +@Singleton +class DefaultAuthTokenLocalDataSource @Inject constructor( + @param:ApplicationContext private val context: Context +) : AuthTokenLocalDataSource { + // Loads the token from Preferences DataStore for the app launch session decision. + override suspend fun getToken(): String? { + return context.authSessionDataStore.data.first()[AuthTokenKey] + } + + // Stores the token in Preferences DataStore after a temporary authentication success. + override suspend fun saveToken(token: String) { + context.authSessionDataStore.edit { preferences -> + preferences[AuthTokenKey] = token + } + } + + // Deletes the token from Preferences DataStore for a future logout flow. + override suspend fun clearToken() { + context.authSessionDataStore.edit { preferences -> + preferences.remove(AuthTokenKey) + } + } + + private companion object { + val AuthTokenKey = stringPreferencesKey("auth_token") + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/AuthSessionRepository.kt b/app/src/main/java/com/example/it_da/data/repository/AuthSessionRepository.kt new file mode 100644 index 0000000..d76e355 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/AuthSessionRepository.kt @@ -0,0 +1,12 @@ +package com.example.it_da.data.repository + +interface AuthSessionRepository { + // Checks whether a non-empty local token is available for automatic login. + suspend fun hasStoredToken(): Result + + // Saves a temporary token until the server authentication contract is connected. + suspend fun saveTemporaryToken(): Result + + // Clears the stored token for the future logout flow. + suspend fun clearToken(): Result +} diff --git a/app/src/main/java/com/example/it_da/data/repository/DefaultAuthSessionRepository.kt b/app/src/main/java/com/example/it_da/data/repository/DefaultAuthSessionRepository.kt new file mode 100644 index 0000000..f13ae3c --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/DefaultAuthSessionRepository.kt @@ -0,0 +1,33 @@ +package com.example.it_da.data.repository + +import com.example.it_da.data.local.AuthTokenLocalDataSource +import javax.inject.Inject +import javax.inject.Singleton + +private const val TemporaryAuthToken = "temporary-auth-token" + +@Singleton +class DefaultAuthSessionRepository @Inject constructor( + private val authTokenLocalDataSource: AuthTokenLocalDataSource +) : AuthSessionRepository { + // Treats any non-empty local token as an authenticated session until server validation is connected. + override suspend fun hasStoredToken(): Result { + return runCatching { + !authTokenLocalDataSource.getToken().isNullOrBlank() + } + } + + // Persists a placeholder token so the pre-server authentication flow can be verified end to end. + override suspend fun saveTemporaryToken(): Result { + return runCatching { + authTokenLocalDataSource.saveToken(TemporaryAuthToken) + } + } + + // Removes the current token behind the repository boundary for the future logout UI. + override suspend fun clearToken(): Result { + return runCatching { + authTokenLocalDataSource.clearToken() + } + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt b/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt index 781a96b..c3de6d2 100644 --- a/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt +++ b/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt @@ -4,9 +4,11 @@ import android.content.Context import com.example.it_da.data.auth.SocialAuthClient import com.example.it_da.domain.model.SocialAuthAccount import com.example.it_da.domain.model.SocialAuthProvider +import javax.inject.Inject +import kotlin.jvm.JvmSuppressWildcards -class DefaultSocialAuthRepository( - private val socialAuthClients: List +class DefaultSocialAuthRepository @Inject constructor( + private val socialAuthClients: List<@JvmSuppressWildcards SocialAuthClient> ) : SocialAuthRepository { // Delegates authentication to the SDK client that owns the requested provider. override suspend fun authenticate( diff --git a/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt b/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt index c2b4488..1c4c6c7 100644 --- a/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt +++ b/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt @@ -1,74 +1,158 @@ package com.example.it_da.data.repository +import com.example.it_da.data.store.NotificationStore +import com.example.it_da.data.store.ProjectStore +import com.example.it_da.data.store.UserStore import com.example.it_da.domain.model.HomeDashboard -import com.example.it_da.domain.model.HomeNotification -import com.example.it_da.domain.model.HomeNotificationType -import com.example.it_da.domain.model.HomeParticipatingProject -import com.example.it_da.domain.model.HomeProjectCount -import com.example.it_da.domain.model.HomeRecommendedProject +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectCount +import com.example.it_da.domain.model.ProjectStoreState +import com.example.it_da.domain.model.RecommendedProject +import com.example.it_da.domain.model.UserSummary +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock -class FakeHomeRepository : HomeRepository { - // Returns sample home data through the same repository boundary that a server implementation will use. - override suspend fun getHomeDashboard(): Result { - return Result.success( - HomeDashboard( - userName = "000", - greetingDescription = "상상은 여기서 현실이 됩니다.\n당신의 프로젝트와 팀을 찾아보세요", - projectCount = HomeProjectCount( - applyingCount = 3, - participatingCount = 1, - completedCount = 1 - ), - recommendedProjects = listOf( - HomeRecommendedProject( - id = "recommended-ai-planner", - title = "AI 기반 학습 플래너 [0부0부]", - recruitingSummary = "백엔드 개발자 1명 모집", - statusText = "모집 중", - techStacks = listOf("Back-end"), - participantSummary = "IoT과ㆍ2명, SW과 1명 참여" - ), - HomeRecommendedProject( - id = "recommended-hachiware", - title = "하지와레 키우기 [하키]", - recruitingSummary = "프론트엔드 개발자 2명 모집", - statusText = "모집 중", - techStacks = listOf("Back-end"), - participantSummary = "IoT과ㆍ2명, SW과 1명 참여" - ), - HomeRecommendedProject( - id = "recommended-pokemon", - title = "닮은 포켓몬 검사 [포켓몬백]", - recruitingSummary = "iOS 개발자ㆍ1명ㆍ디자이너 1명 모집", - statusText = "마감 임박", - techStacks = listOf("iOS", "Design"), - participantSummary = "IoT과ㆍ2명, SW과 1명 참여" - ) - ), - participatingProjects = listOf( - HomeParticipatingProject( - id = "participating-dalbal", - title = "사랑을 이어주는 앱 [달발]", - myRole = "내 역할 : iOS 개발", - statusText = "진행 중", - teamSummary = "팀원 4명ㆍ마감 2026-05-31" +private const val HomeNotificationSummaryLimit = 2 + +class FakeHomeRepository @Inject constructor( + private val userStore: UserStore, + private val projectStore: ProjectStore, + private val notificationStore: NotificationStore +) : HomeRepository { + private val initializationMutex = Mutex() + private var isInitialized = false + + override val homeDashboard = combine( + userStore.userSummary, + projectStore.state, + notificationStore.notifications + ) { userSummary, projectState, notifications -> + HomeDashboard( + userName = userSummary.userName, + greetingDescription = userSummary.greetingDescription, + projectCount = projectState.projectCount, + recommendedProjects = projectState.recommendedProjects, + participatingProjects = projectState.participatingProjects, + notifications = notifications.take(HomeNotificationSummaryLimit) + ) + } + + // Loads sample server data once so later Store updates survive home screen re-entry. + override suspend fun refreshDashboard(): Result { + return runCatching { + initializationMutex.withLock { + if (isInitialized) { + return@withLock + } + + val dashboard = createInitialHomeDashboard() + userStore.replaceUserSummary( + UserSummary( + userName = dashboard.userName, + greetingDescription = dashboard.greetingDescription ) - ), - notifications = listOf( - HomeNotification( - id = "notification-message", - type = HomeNotificationType.MESSAGE, - message = "지원한 프로젝트에서 새 메시지가 있습니다", - elapsedTime = "2분전" - ), - HomeNotification( - id = "notification-join", - type = HomeNotificationType.PROJECT_JOIN, - message = "백엔드 개발자 1명이 프로젝트에 합류 하였습니다", - elapsedTime = "2분전" + ) + projectStore.replaceState( + ProjectStoreState( + projectCount = dashboard.projectCount, + recommendedProjects = dashboard.recommendedProjects, + participatingProjects = dashboard.participatingProjects ) ) + notificationStore.replaceNotifications(dashboard.notifications) + isInitialized = true + } + } + } +} + +// Supplies fake server values until dashboard endpoints are connected. +private fun createInitialHomeDashboard(): HomeDashboard { + return HomeDashboard( + userName = "000", + greetingDescription = "상상은 여기서 현실이 됩니다.\n당신의 프로젝트와 팀을 찾아보세요", + projectCount = ProjectCount( + applyingCount = 3, + participatingCount = 1, + completedCount = 1 + ), + recommendedProjects = listOf( + RecommendedProject( + id = "recommended-ai-planner", + title = "AI 기반 학습 플래너 [0부0부]", + recruitingSummary = "백엔드 개발자 1명 모집", + statusText = "모집 중", + techStacks = listOf("Back-end"), + participantSummary = "IoT과ㆍ2명, SW과 1명 참여" + ), + RecommendedProject( + id = "recommended-hachiware", + title = "하지와레 키우기 [하키]", + recruitingSummary = "프론트엔드 개발자 2명 모집", + statusText = "모집 중", + techStacks = listOf("Back-end"), + participantSummary = "IoT과ㆍ2명, SW과 1명 참여" + ), + RecommendedProject( + id = "recommended-pokemon", + title = "닮은 포켓몬 검사 [포켓몬백]", + recruitingSummary = "iOS 개발자ㆍ1명ㆍ디자이너 1명 모집", + statusText = "마감 임박", + techStacks = listOf("iOS", "Design"), + participantSummary = "IoT과ㆍ2명, SW과 1명 참여" + ) + ), + participatingProjects = listOf( + ParticipatingProject( + id = "participating-dalbal", + title = "사랑을 이어주는 앱 [달발]", + myRole = "내 역할 : iOS 개발", + statusText = "진행 중", + teamSummary = "팀원 4명ㆍ마감 2026-05-31" ) + ), + notifications = createInitialNotifications() + ) +} + +// Supplies one shared fake notification list for both the home summary and notification screen. +private fun createInitialNotifications(): List { + return listOf( + Notification( + id = "new-project-recommendation", + type = NotificationType.MESSAGE, + title = "새 프로젝트 추천", + message = "나의 기술 스택과 일치하는 프로그램이 등록되었습니다.", + elapsedTime = "5분 전", + isRead = false + ), + Notification( + id = "application-result", + type = NotificationType.MESSAGE, + title = "지원 결과 도착", + message = "백엔드 개발자 1명이 팀에 합류했습니다.", + elapsedTime = "1시간 전", + isRead = false + ), + Notification( + id = "team-member-joined", + type = NotificationType.PROJECT_JOIN, + title = "팀 멤버 합류", + message = "백엔드 개발자 1명이 팀에 합류했습니다.", + elapsedTime = "1시간 전", + isRead = true + ), + Notification( + id = "updated-project-recommendation", + type = NotificationType.MESSAGE, + title = "새 프로젝트 추천", + message = "관심 분야 추천 프로젝트가 새로 업데이트 되었습니다.", + elapsedTime = "1시간 전", + isRead = true ) - } + ) } diff --git a/app/src/main/java/com/example/it_da/data/repository/FakeNotificationRepository.kt b/app/src/main/java/com/example/it_da/data/repository/FakeNotificationRepository.kt new file mode 100644 index 0000000..caa6f5a --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/FakeNotificationRepository.kt @@ -0,0 +1,22 @@ +package com.example.it_da.data.repository + +import com.example.it_da.data.store.NotificationStore +import javax.inject.Inject + +class FakeNotificationRepository @Inject constructor( + private val notificationStore: NotificationStore +) : NotificationRepository { + override val notifications = notificationStore.notifications + + // Updates shared fake notification state until a server endpoint is connected. + override suspend fun markAsRead(notificationId: String): Result { + notificationStore.markAsRead(notificationId) + return Result.success(Unit) + } + + // Updates every shared fake notification until a server endpoint is connected. + override suspend fun markAllAsRead(): Result { + notificationStore.markAllAsRead() + return Result.success(Unit) + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/FakeProjectCreateRepository.kt b/app/src/main/java/com/example/it_da/data/repository/FakeProjectCreateRepository.kt new file mode 100644 index 0000000..39e2154 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/FakeProjectCreateRepository.kt @@ -0,0 +1,26 @@ +package com.example.it_da.data.repository + +import com.example.it_da.domain.model.ProjectCreateProject +import com.example.it_da.data.store.ProjectStore +import com.example.it_da.domain.model.ParticipatingProject +import javax.inject.Inject + +class FakeProjectCreateRepository @Inject constructor( + private val projectStore: ProjectStore +) : ProjectCreateRepository { + private var nextCreatedProjectId = 1 + + // Adds a fake server-created project to shared Store state until the real endpoint is connected. + override suspend fun createProject(projectCreateProject: ProjectCreateProject): Result { + projectStore.addCreatedProject( + ParticipatingProject( + id = "created-project-${nextCreatedProjectId++}", + title = projectCreateProject.projectName, + myRole = "내 역할 : 프로젝트 생성자", + statusText = "모집 중", + teamSummary = "팀원 1명ㆍ마감 ${projectCreateProject.deadline}" + ) + ) + return Result.success(Unit) + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt b/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt index 3633aef..e9a77c8 100644 --- a/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt +++ b/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt @@ -1,8 +1,11 @@ package com.example.it_da.data.repository import com.example.it_da.domain.model.HomeDashboard +import kotlinx.coroutines.flow.Flow interface HomeRepository { - // Loads the home dashboard data that will later come from the server. - suspend fun getHomeDashboard(): Result + val homeDashboard: Flow + + // Refreshes shared Store values from the dashboard source without exposing data sources to UI. + suspend fun refreshDashboard(): Result } diff --git a/app/src/main/java/com/example/it_da/data/repository/NotificationRepository.kt b/app/src/main/java/com/example/it_da/data/repository/NotificationRepository.kt new file mode 100644 index 0000000..ff5fe2b --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/NotificationRepository.kt @@ -0,0 +1,14 @@ +package com.example.it_da.data.repository + +import com.example.it_da.domain.model.Notification +import kotlinx.coroutines.flow.Flow + +interface NotificationRepository { + val notifications: Flow> + + // Marks one notification as read through the data layer boundary. + suspend fun markAsRead(notificationId: String): Result + + // Marks all notifications as read through the data layer boundary. + suspend fun markAllAsRead(): Result +} diff --git a/app/src/main/java/com/example/it_da/data/repository/ProjectCreateRepository.kt b/app/src/main/java/com/example/it_da/data/repository/ProjectCreateRepository.kt new file mode 100644 index 0000000..9a3efc8 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/ProjectCreateRepository.kt @@ -0,0 +1,8 @@ +package com.example.it_da.data.repository + +import com.example.it_da.domain.model.ProjectCreateProject + +interface ProjectCreateRepository { + // Submits project creation values through the data layer boundary. + suspend fun createProject(projectCreateProject: ProjectCreateProject): Result +} diff --git a/app/src/main/java/com/example/it_da/data/store/DefaultNotificationStore.kt b/app/src/main/java/com/example/it_da/data/store/DefaultNotificationStore.kt new file mode 100644 index 0000000..9d44ccd --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/DefaultNotificationStore.kt @@ -0,0 +1,39 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.Notification +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class DefaultNotificationStore @Inject constructor() : NotificationStore { + private val _notifications = MutableStateFlow>(emptyList()) + override val notifications = _notifications.asStateFlow() + + // Publishes the latest notification list as the shared in-memory value. + override fun replaceNotifications(notifications: List) { + _notifications.value = notifications + } + + // Updates only the selected notification while preserving list order. + override fun markAsRead(notificationId: String) { + _notifications.update { notifications -> + notifications.map { notification -> + if (notification.id == notificationId) { + notification.copy(isRead = true) + } else { + notification + } + } + } + } + + // Updates all notifications with a single shared state publication. + override fun markAllAsRead() { + _notifications.update { notifications -> + notifications.map { notification -> + notification.copy(isRead = true) + } + } + } +} diff --git a/app/src/main/java/com/example/it_da/data/store/DefaultProjectStore.kt b/app/src/main/java/com/example/it_da/data/store/DefaultProjectStore.kt new file mode 100644 index 0000000..36422f3 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/DefaultProjectStore.kt @@ -0,0 +1,30 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectStoreState +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class DefaultProjectStore @Inject constructor() : ProjectStore { + private val _state = MutableStateFlow(ProjectStoreState()) + override val state = _state.asStateFlow() + + // Publishes a complete project snapshot so all project consumers stay consistent. + override fun replaceState(state: ProjectStoreState) { + _state.value = state + } + + // Prepends a created project and increments the participating count in one state update. + override fun addCreatedProject(project: ParticipatingProject) { + _state.update { currentState -> + currentState.copy( + projectCount = currentState.projectCount.copy( + participatingCount = currentState.projectCount.participatingCount + 1 + ), + participatingProjects = listOf(project) + currentState.participatingProjects + ) + } + } +} diff --git a/app/src/main/java/com/example/it_da/data/store/DefaultUserStore.kt b/app/src/main/java/com/example/it_da/data/store/DefaultUserStore.kt new file mode 100644 index 0000000..c1518df --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/DefaultUserStore.kt @@ -0,0 +1,21 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.UserSummary +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class DefaultUserStore @Inject constructor() : UserStore { + private val _userSummary = MutableStateFlow( + UserSummary( + userName = "", + greetingDescription = "" + ) + ) + override val userSummary = _userSummary.asStateFlow() + + // Publishes the latest user summary as the shared in-memory value. + override fun replaceUserSummary(userSummary: UserSummary) { + _userSummary.value = userSummary + } +} diff --git a/app/src/main/java/com/example/it_da/data/store/NotificationStore.kt b/app/src/main/java/com/example/it_da/data/store/NotificationStore.kt new file mode 100644 index 0000000..a35c8f2 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/NotificationStore.kt @@ -0,0 +1,17 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.Notification +import kotlinx.coroutines.flow.StateFlow + +interface NotificationStore { + val notifications: StateFlow> + + // Replaces shared notifications after a repository refresh succeeds. + fun replaceNotifications(notifications: List) + + // Marks one shared notification as read for every observing screen. + fun markAsRead(notificationId: String) + + // Marks every shared notification as read for every observing screen. + fun markAllAsRead() +} diff --git a/app/src/main/java/com/example/it_da/data/store/ProjectStore.kt b/app/src/main/java/com/example/it_da/data/store/ProjectStore.kt new file mode 100644 index 0000000..61c4d2d --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/ProjectStore.kt @@ -0,0 +1,15 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectStoreState +import kotlinx.coroutines.flow.StateFlow + +interface ProjectStore { + val state: StateFlow + + // Replaces all shared project values after a repository refresh succeeds. + fun replaceState(state: ProjectStoreState) + + // Adds a newly created project to the current user's participating projects. + fun addCreatedProject(project: ParticipatingProject) +} diff --git a/app/src/main/java/com/example/it_da/data/store/UserStore.kt b/app/src/main/java/com/example/it_da/data/store/UserStore.kt new file mode 100644 index 0000000..21a6943 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/UserStore.kt @@ -0,0 +1,11 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.UserSummary +import kotlinx.coroutines.flow.StateFlow + +interface UserStore { + val userSummary: StateFlow + + // Replaces shared user summary values after a repository loads authoritative data. + fun replaceUserSummary(userSummary: UserSummary) +} diff --git a/app/src/main/java/com/example/it_da/di/RepositoryModule.kt b/app/src/main/java/com/example/it_da/di/RepositoryModule.kt new file mode 100644 index 0000000..d59af03 --- /dev/null +++ b/app/src/main/java/com/example/it_da/di/RepositoryModule.kt @@ -0,0 +1,75 @@ +package com.example.it_da.di + +import com.example.it_da.data.local.AuthTokenLocalDataSource +import com.example.it_da.data.local.DefaultAuthTokenLocalDataSource +import com.example.it_da.data.repository.AuthSessionRepository +import com.example.it_da.data.repository.DefaultAuthSessionRepository +import com.example.it_da.data.repository.DefaultSocialAuthRepository +import com.example.it_da.data.repository.FakeHomeRepository +import com.example.it_da.data.repository.FakeNotificationRepository +import com.example.it_da.data.repository.FakeProjectCreateRepository +import com.example.it_da.data.repository.HomeRepository +import com.example.it_da.data.repository.NotificationRepository +import com.example.it_da.data.repository.ProjectCreateRepository +import com.example.it_da.data.repository.SocialAuthRepository +import com.example.it_da.data.store.DefaultNotificationStore +import com.example.it_da.data.store.DefaultProjectStore +import com.example.it_da.data.store.DefaultUserStore +import com.example.it_da.data.store.NotificationStore +import com.example.it_da.data.store.ProjectStore +import com.example.it_da.data.store.UserStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindAuthTokenLocalDataSource( + defaultAuthTokenLocalDataSource: DefaultAuthTokenLocalDataSource + ): AuthTokenLocalDataSource + + @Binds + @Singleton + abstract fun bindAuthSessionRepository( + defaultAuthSessionRepository: DefaultAuthSessionRepository + ): AuthSessionRepository + + @Binds + @Singleton + abstract fun bindUserStore(defaultUserStore: DefaultUserStore): UserStore + + @Binds + @Singleton + abstract fun bindProjectStore(defaultProjectStore: DefaultProjectStore): ProjectStore + + @Binds + @Singleton + abstract fun bindNotificationStore(defaultNotificationStore: DefaultNotificationStore): NotificationStore + + @Binds + @Singleton + abstract fun bindHomeRepository(fakeHomeRepository: FakeHomeRepository): HomeRepository + + @Binds + @Singleton + abstract fun bindNotificationRepository( + fakeNotificationRepository: FakeNotificationRepository + ): NotificationRepository + + @Binds + @Singleton + abstract fun bindProjectCreateRepository( + fakeProjectCreateRepository: FakeProjectCreateRepository + ): ProjectCreateRepository + + @Binds + @Singleton + abstract fun bindSocialAuthRepository( + defaultSocialAuthRepository: DefaultSocialAuthRepository + ): SocialAuthRepository +} diff --git a/app/src/main/java/com/example/it_da/di/SocialAuthModule.kt b/app/src/main/java/com/example/it_da/di/SocialAuthModule.kt new file mode 100644 index 0000000..35cf279 --- /dev/null +++ b/app/src/main/java/com/example/it_da/di/SocialAuthModule.kt @@ -0,0 +1,25 @@ +package com.example.it_da.di + +import com.example.it_da.BuildConfig +import com.example.it_da.data.auth.GoogleSocialAuthClient +import com.example.it_da.data.auth.KakaoSocialAuthClient +import com.example.it_da.data.auth.SocialAuthClient +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SocialAuthModule { + // Provides SDK-specific clients behind the shared SocialAuthClient boundary. + @Provides + @Singleton + fun provideSocialAuthClients(): List { + return listOf( + GoogleSocialAuthClient(BuildConfig.GOOGLE_WEB_CLIENT_ID), + KakaoSocialAuthClient(BuildConfig.KAKAO_NATIVE_APP_KEY) + ) + } +} diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt b/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt index 5524e9c..9ae9d86 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt +++ b/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt @@ -4,8 +4,8 @@ package com.example.it_da.domain.model data class HomeDashboard( val userName: String, val greetingDescription: String, - val projectCount: HomeProjectCount, - val recommendedProjects: List, - val participatingProjects: List, - val notifications: List + val projectCount: ProjectCount, + val recommendedProjects: List, + val participatingProjects: List, + val notifications: List ) diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeNotification.kt b/app/src/main/java/com/example/it_da/domain/model/HomeNotification.kt deleted file mode 100644 index da22179..0000000 --- a/app/src/main/java/com/example/it_da/domain/model/HomeNotification.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.it_da.domain.model - -// Represents one notification summary received from the home data source. -data class HomeNotification( - val id: String, - val type: HomeNotificationType, - val message: String, - val elapsedTime: String -) diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeNotificationType.kt b/app/src/main/java/com/example/it_da/domain/model/HomeNotificationType.kt deleted file mode 100644 index 1b17c0a..0000000 --- a/app/src/main/java/com/example/it_da/domain/model/HomeNotificationType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.it_da.domain.model - -// Identifies which app image should represent a home notification. -enum class HomeNotificationType { - MESSAGE, - PROJECT_JOIN -} diff --git a/app/src/main/java/com/example/it_da/domain/model/Notification.kt b/app/src/main/java/com/example/it_da/domain/model/Notification.kt new file mode 100644 index 0000000..c1cc10a --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/Notification.kt @@ -0,0 +1,11 @@ +package com.example.it_da.domain.model + +// Represents one notification shared by the home summary and notification screen. +data class Notification( + val id: String, + val type: NotificationType, + val title: String, + val message: String, + val elapsedTime: String, + val isRead: Boolean +) diff --git a/app/src/main/java/com/example/it_da/domain/model/NotificationType.kt b/app/src/main/java/com/example/it_da/domain/model/NotificationType.kt new file mode 100644 index 0000000..c16a5cd --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/NotificationType.kt @@ -0,0 +1,7 @@ +package com.example.it_da.domain.model + +// Identifies the shared category of an app notification. +enum class NotificationType { + MESSAGE, + PROJECT_JOIN +} diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeParticipatingProject.kt b/app/src/main/java/com/example/it_da/domain/model/ParticipatingProject.kt similarity index 86% rename from app/src/main/java/com/example/it_da/domain/model/HomeParticipatingProject.kt rename to app/src/main/java/com/example/it_da/domain/model/ParticipatingProject.kt index c25bf31..b202b49 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeParticipatingProject.kt +++ b/app/src/main/java/com/example/it_da/domain/model/ParticipatingProject.kt @@ -1,7 +1,7 @@ package com.example.it_da.domain.model // Represents a project that the current user is already participating in. -data class HomeParticipatingProject( +data class ParticipatingProject( val id: String, val title: String, val myRole: String, diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeProjectCount.kt b/app/src/main/java/com/example/it_da/domain/model/ProjectCount.kt similarity index 55% rename from app/src/main/java/com/example/it_da/domain/model/HomeProjectCount.kt rename to app/src/main/java/com/example/it_da/domain/model/ProjectCount.kt index 53c8f32..b287292 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeProjectCount.kt +++ b/app/src/main/java/com/example/it_da/domain/model/ProjectCount.kt @@ -1,7 +1,7 @@ package com.example.it_da.domain.model -// Represents the user's project activity counts from the home data source. -data class HomeProjectCount( +// Represents the user's shared project activity counts. +data class ProjectCount( val applyingCount: Int, val participatingCount: Int, val completedCount: Int diff --git a/app/src/main/java/com/example/it_da/domain/model/ProjectCreateProject.kt b/app/src/main/java/com/example/it_da/domain/model/ProjectCreateProject.kt new file mode 100644 index 0000000..9048f0f --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/ProjectCreateProject.kt @@ -0,0 +1,14 @@ +package com.example.it_da.domain.model + +data class ProjectCreateProject( + val projectName: String, + val category: String, + val period: String, + val method: String, + val introduction: String, + val goal: String, + val memberCount: String, + val role: String, + val techStack: String, + val deadline: String +) diff --git a/app/src/main/java/com/example/it_da/domain/model/ProjectStoreState.kt b/app/src/main/java/com/example/it_da/domain/model/ProjectStoreState.kt new file mode 100644 index 0000000..f158776 --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/ProjectStoreState.kt @@ -0,0 +1,12 @@ +package com.example.it_da.domain.model + +// Groups project values so Store updates publish one consistent project snapshot. +data class ProjectStoreState( + val projectCount: ProjectCount = ProjectCount( + applyingCount = 0, + participatingCount = 0, + completedCount = 0 + ), + val recommendedProjects: List = emptyList(), + val participatingProjects: List = emptyList() +) diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeRecommendedProject.kt b/app/src/main/java/com/example/it_da/domain/model/RecommendedProject.kt similarity index 66% rename from app/src/main/java/com/example/it_da/domain/model/HomeRecommendedProject.kt rename to app/src/main/java/com/example/it_da/domain/model/RecommendedProject.kt index 95ac873..2a3bfd8 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeRecommendedProject.kt +++ b/app/src/main/java/com/example/it_da/domain/model/RecommendedProject.kt @@ -1,7 +1,7 @@ package com.example.it_da.domain.model -// Represents a recommended project received from the home data source. -data class HomeRecommendedProject( +// Represents a recommended project shared by project-related screens. +data class RecommendedProject( val id: String, val title: String, val recruitingSummary: String, diff --git a/app/src/main/java/com/example/it_da/domain/model/UserSummary.kt b/app/src/main/java/com/example/it_da/domain/model/UserSummary.kt new file mode 100644 index 0000000..1be45a9 --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/UserSummary.kt @@ -0,0 +1,7 @@ +package com.example.it_da.domain.model + +// Represents user information shared by screens that display profile summaries. +data class UserSummary( + val userName: String, + val greetingDescription: String +) diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/component/HomeBottomNavigationBar.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaBottomNavigationBar.kt similarity index 66% rename from app/src/main/java/com/example/it_da/ui/screen/home/component/HomeBottomNavigationBar.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaBottomNavigationBar.kt index 08538cf..6f6a1f3 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/component/HomeBottomNavigationBar.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaBottomNavigationBar.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.screen.home.component +package com.example.it_da.ui.commonComponent import androidx.annotation.DrawableRes import androidx.compose.foundation.BorderStroke @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,23 +22,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import com.example.it_da.R -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaHomeDividerGray import com.example.it_da.ui.theme.ItdaSecondaryTextColor import com.example.it_da.ui.theme.ItdaWhite -private val HomeBottomNavigationBarHeight = 52.5.dp -private val HomeBottomNavigationContainerHeight = 62.dp -private val HomeBottomNavigationItemHeight = 48.dp +private val ItdaBottomNavigationBarHeight = 52.5.dp +private val ItdaBottomNavigationContainerHeight = 62.dp +private val ItdaBottomNavigationItemHeight = 48.dp +private val ItdaCenterNavigationClickSize = 52.dp +private val ItdaBottomNavigationIconLabelSpacing = 2.dp // Shows the fixed bottom navigation bar and exposes each tab as a callback. @Composable -fun HomeBottomNavigationBar( +fun ItdaBottomNavigationBar( onHomeClick: () -> Unit, onExploreClick: () -> Unit, onCreateProjectClick: () -> Unit, @@ -48,13 +50,13 @@ fun HomeBottomNavigationBar( Box( modifier = modifier .fillMaxWidth() - .height(HomeBottomNavigationContainerHeight) + .height(ItdaBottomNavigationContainerHeight) ) { Surface( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .height(HomeBottomNavigationBarHeight), + .height(ItdaBottomNavigationBarHeight), color = ItdaWhite, border = BorderStroke(1.dp, ItdaHomeDividerGray) ) { @@ -63,47 +65,49 @@ fun HomeBottomNavigationBar( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - HomeBottomNavigationItem( + ItdaBottomNavigationItem( iconResId = R.drawable.bottom_bar_home, - label = "홈", - contentDescription = "홈", + label = stringResource(id = R.string.home_bottom_tab_home), + contentDescription = stringResource(id = R.string.home_bottom_tab_home), iconSize = 25.dp, onClick = onHomeClick ) - HomeBottomNavigationItem( + ItdaBottomNavigationItem( iconResId = R.drawable.bottom_bar_research, - label = "탐색", - contentDescription = "탐색", + label = stringResource(id = R.string.home_bottom_tab_explore), + contentDescription = stringResource(id = R.string.home_bottom_tab_explore), iconSize = 25.dp, onClick = onExploreClick ) Spacer( modifier = Modifier - .width(52.dp) - .height(HomeBottomNavigationItemHeight) + .width(ItdaCenterNavigationClickSize) + .height(ItdaBottomNavigationItemHeight) ) - HomeBottomNavigationItem( + ItdaBottomNavigationItem( iconResId = R.drawable.bottom_bar_bell, - label = "알림", - contentDescription = "알림", + label = stringResource(id = R.string.home_bottom_tab_notification), + contentDescription = stringResource( + id = R.string.home_bottom_tab_notification + ), iconSize = 25.dp, onClick = onNotificationClick ) - HomeBottomNavigationItem( + ItdaBottomNavigationItem( iconResId = R.drawable.bottom_bar_profile, - label = "프로필", - contentDescription = "프로필", + label = stringResource(id = R.string.home_bottom_tab_profile), + contentDescription = stringResource(id = R.string.home_bottom_tab_profile), iconSize = 25.dp, onClick = onProfileClick ) } } - HomeCenterNavigationButton( + ItdaCenterNavigationButton( onClick = onCreateProjectClick, modifier = Modifier.align(Alignment.TopCenter) ) @@ -112,7 +116,7 @@ fun HomeBottomNavigationBar( // Shows a normal labeled bottom navigation item. @Composable -private fun HomeBottomNavigationItem( +private fun ItdaBottomNavigationItem( @DrawableRes iconResId: Int, label: String, contentDescription: String, @@ -123,7 +127,7 @@ private fun HomeBottomNavigationItem( Column( modifier = modifier .width(52.dp) - .height(HomeBottomNavigationItemHeight) + .height(ItdaBottomNavigationItemHeight) .clip(RoundedCornerShape(8.dp)) .clickable(onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, @@ -135,35 +139,35 @@ private fun HomeBottomNavigationItem( modifier = Modifier.size(iconSize) ) - Spacer(modifier = Modifier.height(2.dp)) + Spacer(modifier = Modifier.height(ItdaBottomNavigationIconLabelSpacing)) Text( text = label, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 9.sp, - lineHeight = 9.sp + style = MaterialTheme.typography.labelSmall ) } } // Shows the center add-project action as the prominent middle bottom button. @Composable -private fun HomeCenterNavigationButton( +private fun ItdaCenterNavigationButton( onClick: () -> Unit, modifier: Modifier = Modifier ) { Box( modifier = modifier - .size(45.dp) + .size(ItdaCenterNavigationClickSize) + .zIndex(1f) .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.bottom_bar_center), - contentDescription = "프로젝트 추가", + contentDescription = stringResource( + id = R.string.home_bottom_create_project_description + ), modifier = Modifier.size(45.dp) ) } diff --git a/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCard.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCard.kt new file mode 100644 index 0000000..6400ed8 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCard.kt @@ -0,0 +1,23 @@ +package com.example.it_da.ui.commonComponent + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.it_da.ui.theme.ItdaWhite + +// Provides the shared card appearance while leaving all content and interactions to its caller. +@Composable +fun ItdaCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = ItdaWhite, + border = ItdaCardDefaults.outlinedBorder(), + content = content + ) +} diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaCardDefaults.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCardDefaults.kt similarity index 90% rename from app/src/main/java/com/example/it_da/ui/component/ItdaCardDefaults.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCardDefaults.kt index 0882cc2..b24b81e 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaCardDefaults.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCardDefaults.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.BorderStroke import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpDropdownTextField.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaDropdownTextField.kt similarity index 85% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpDropdownTextField.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaDropdownTextField.kt index f37a043..247e058 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpDropdownTextField.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaDropdownTextField.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource @@ -23,21 +23,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.it_da.R -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaInputBorderGray import com.example.it_da.ui.theme.ItdaPlaceholderTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor -private val SignUpDropdownInputTextWeight = FontWeight(600) - // Draws a rounded input with a button arrow reserved for a later dropdown screen. @Composable -fun SignUpDropdownTextField( +fun ItdaDropdownTextField( label: String, value: String, onValueChange: (String) -> Unit, @@ -47,17 +42,12 @@ fun SignUpDropdownTextField( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val inputTextStyle = TextStyle( - fontFamily = DotSans, - fontWeight = SignUpDropdownInputTextWeight, - fontSize = 15.sp, - lineHeight = 15.sp, - letterSpacing = 0.sp, + val inputTextStyle = MaterialTheme.typography.labelLarge.copy( color = MaterialTheme.colorScheme.onBackground ) Column(modifier = modifier.fillMaxWidth()) { - SignUpFieldLabel(text = label) + ItdaFieldLabel(text = label) Row( modifier = Modifier @@ -102,7 +92,9 @@ fun SignUpDropdownTextField( ) { Icon( painter = painterResource(id = R.drawable.ic_down_arrow), - contentDescription = "open options", + contentDescription = stringResource( + id = R.string.common_dropdown_open_description + ), tint = ItdaSecondaryTextColor, modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaImageButton.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaImageButton.kt new file mode 100644 index 0000000..50fef07 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaImageButton.kt @@ -0,0 +1,36 @@ +package com.example.it_da.ui.commonComponent + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource + +// Displays a drawable inside a reusable clickable image area. +@Composable +fun ItdaImageButton( + @DrawableRes imageResId: Int, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, + shape: Shape +) { + Box( + modifier = modifier + .clip(shape) + .clickable(onClick = onClick) + ) { + Image( + painter = painterResource(id = imageResId), + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = imageModifier + ) + } +} diff --git a/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaLayoutDefaults.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaLayoutDefaults.kt new file mode 100644 index 0000000..efbe393 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaLayoutDefaults.kt @@ -0,0 +1,23 @@ +package com.example.it_da.ui.commonComponent + +import androidx.compose.ui.unit.dp + +object ItdaLayoutDefaults { + // Keeps scrollable screen content visible above the fixed bottom navigation bar. + val BottomNavigationContentPadding = 80.dp + + // Provides the standard horizontal padding for form-style screens. + val FormHorizontalPadding = 30.dp + + // Provides the short vertical gap used between closely related form fields. + val ShortVerticalSpacing = 15.dp + + // Provides the long vertical gap used between separated screen sections. + val LongVerticalSpacing = 30.dp + + // Separates a section heading from the content displayed below it. + val SectionHeaderContentSpacing = 13.dp + + // Separates project cards displayed together inside a project section. + val ProjectCardSpacing = 15.dp +} diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaOutlinedBadge.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedBadge.kt similarity index 77% rename from app/src/main/java/com/example/it_da/ui/component/ItdaOutlinedBadge.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedBadge.kt index ac4b725..af3d220 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaOutlinedBadge.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedBadge.kt @@ -1,16 +1,14 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaGuideGray import com.example.it_da.ui.theme.ItdaSecondaryTextColor @@ -29,10 +27,7 @@ fun ItdaOutlinedBadge( Text( text = text, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 12.sp, + style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpOutlinedTextField.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedTextField.kt similarity index 73% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpOutlinedTextField.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedTextField.kt index 3f36f9a..a04ce02 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpOutlinedTextField.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedTextField.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource @@ -18,60 +18,55 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.theme.ItdaPlaceholderTextColor import com.example.it_da.ui.theme.ItdaInputBorderGray -import com.example.it_da.ui.theme.DotSans - -private val SignUpFieldLabelWeight = FontWeight.Medium -private val SignUpInputTextWeight = FontWeight(600) +import com.example.it_da.ui.theme.ItdaPlaceholderTextColor +import com.example.it_da.ui.theme.ItdaSectionTextColor // Draws a labeled rounded input that hides its example text while focused or filled. @Composable -fun SignUpOutlinedTextField( +fun ItdaOutlinedTextField( label: String, value: String, onValueChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier, - visualTransformation: VisualTransformation = VisualTransformation.None + visualTransformation: VisualTransformation = VisualTransformation.None, + inputHeight: Dp = 40.dp, + singleLine: Boolean = true ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val inputTextStyle = TextStyle( - fontFamily = DotSans, - fontWeight = SignUpInputTextWeight, - fontSize = 15.sp, - lineHeight = 15.sp, - letterSpacing = 0.sp, + val inputTextStyle = MaterialTheme.typography.labelLarge.copy( color = MaterialTheme.colorScheme.onBackground ) Column(modifier = modifier.fillMaxWidth()) { - SignUpFieldLabel(text = label) + ItdaFieldLabel(text = label) Box( modifier = Modifier .fillMaxWidth() - .height(40.dp) + .height(inputHeight) .border( width = 1.2.dp, color = ItdaInputBorderGray, shape = RoundedCornerShape(10.dp) ) - .padding(horizontal = 11.dp), - contentAlignment = Alignment.CenterStart + .padding( + horizontal = 11.dp, + vertical = if (singleLine) 0.dp else 11.dp + ), + contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart ) { BasicTextField( value = value, onValueChange = onValueChange, modifier = Modifier.fillMaxWidth(), textStyle = inputTextStyle, - singleLine = true, + singleLine = singleLine, cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), visualTransformation = visualTransformation, interactionSource = interactionSource, @@ -90,20 +85,16 @@ fun SignUpOutlinedTextField( } } -// Draws the field label shared by all sign-up form inputs. +// Draws the field label shared by reusable form inputs. @Composable -fun SignUpFieldLabel( +fun ItdaFieldLabel( text: String, modifier: Modifier = Modifier ) { Text( text = text, modifier = modifier.padding(bottom = 12.dp), - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = SignUpFieldLabelWeight, - fontSize = 16.sp, - lineHeight = 16.sp - ) + color = ItdaSectionTextColor, + style = MaterialTheme.typography.titleMedium ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpPrimaryButton.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaPrimaryButton.kt similarity index 72% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpPrimaryButton.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaPrimaryButton.kt index 3bc517c..fa10f04 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpPrimaryButton.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaPrimaryButton.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box @@ -13,23 +13,23 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.example.it_da.R import com.example.it_da.ui.theme.ItdaLoginButtonDisabledBlack import com.example.it_da.ui.theme.ItdaButtonTextColor -private val SignUpPrimaryButtonTextWeight = FontWeight(600) - -// Shows the sign-up primary action and lets Button enforce the enabled click rule. +// Shows a primary action and lets Button enforce the enabled click rule. @Composable -fun SignUpPrimaryButton( +fun ItdaPrimaryButton( enabled: Boolean, onClick: () -> Unit, - text: String = "다음으로", + text: String? = null, containerColor: Color = MaterialTheme.colorScheme.onBackground, modifier: Modifier = Modifier ) { + val buttonText = text ?: stringResource(id = R.string.common_next) + Button( onClick = onClick, enabled = enabled, @@ -50,12 +50,8 @@ fun SignUpPrimaryButton( ) { Box(contentAlignment = Alignment.Center) { Text( - text = text, - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = SignUpPrimaryButtonTextWeight, - fontSize = 20.sp, - lineHeight = 20.sp - ) + text = buttonText, + style = MaterialTheme.typography.displaySmall ) } } diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaSectionHeader.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaSectionHeader.kt similarity index 73% rename from app/src/main/java/com/example/it_da/ui/component/ItdaSectionHeader.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaSectionHeader.kt index b4c3ff8..4db807a 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaSectionHeader.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaSectionHeader.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -7,15 +7,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaPrimaryTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor +private val ItdaSectionTitleDescriptionSpacing = 12.dp + // Displays a reusable section title and optional guide text with the app typography. @Composable fun ItdaSectionHeader( @@ -23,14 +23,13 @@ fun ItdaSectionHeader( modifier: Modifier = Modifier, description: String? = null, titleFontSize: TextUnit = 21.sp, - titleFontWeight: FontWeight = FontWeight.Medium + titleFontWeight: FontWeight = FontWeight.SemiBold ) { Column(modifier = modifier) { Text( text = title, color = ItdaPrimaryTextColor, style = MaterialTheme.typography.titleLarge.copy( - fontFamily = DotSans, fontWeight = titleFontWeight, fontSize = titleFontSize, lineHeight = titleFontSize @@ -38,17 +37,12 @@ fun ItdaSectionHeader( ) if (description != null) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(ItdaSectionTitleDescriptionSpacing)) Text( text = description, color = ItdaSecondaryTextColor, - style = TextStyle( - fontFamily = DotSans, - fontWeight = FontWeight.Medium, - fontSize = 13.sp, - lineHeight = 13.sp - ) + style = MaterialTheme.typography.titleSmall ) } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpTopBar.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaTopBar.kt similarity index 52% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpTopBar.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaTopBar.kt index 90c34e1..46c906b 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpTopBar.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaTopBar.kt @@ -1,10 +1,12 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -12,38 +14,48 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.it_da.R import com.example.it_da.ui.theme.ItdaTopBarTitleTextColor -private val SignUpTopBarHeight = 72.dp -private val SignUpTopTitleTopPadding = 22.dp -private val SignUpTopTitleWeight = FontWeight(700) +private val ItdaTopBarHeight = 72.dp +private val ItdaTopTitleTopPadding = 22.dp +private val ItdaTopBackButtonSize = 30.dp -// Draws the sign-up title area and the design line asset below it. +// Draws the shared title area and the design line asset below it. @Composable -fun SignUpTopBar( +fun ItdaTopBar( title: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onBackClick: (() -> Unit)? = null, + backContentDescription: String? = null ) { Box( modifier = modifier .fillMaxWidth() - .height(SignUpTopBarHeight) + .height(ItdaTopBarHeight) ) { + if (onBackClick != null) { + ItdaImageButton( + imageResId = R.drawable.backbutton, + contentDescription = backContentDescription, + onClick = onBackClick, + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 25.dp, top = 25.dp) + .size(ItdaTopBackButtonSize), + imageModifier = Modifier.fillMaxSize(), + shape = MaterialTheme.shapes.small + ) + } + Text( text = title, modifier = Modifier .align(Alignment.TopCenter) - .padding(top = SignUpTopTitleTopPadding), + .padding(top = ItdaTopTitleTopPadding), color = ItdaTopBarTitleTextColor, - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = SignUpTopTitleWeight, - fontSize = 23.sp, - lineHeight = 27.sp - ) + style = MaterialTheme.typography.displayLarge ) Image( diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaUnderlinedTextButton.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaUnderlinedTextButton.kt similarity index 73% rename from app/src/main/java/com/example/it_da/ui/component/ItdaUnderlinedTextButton.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaUnderlinedTextButton.kt index 29e9395..de5efe8 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaUnderlinedTextButton.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaUnderlinedTextButton.kt @@ -1,18 +1,16 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaSecondaryTextColor // Shows a compact underlined text action for secondary navigation inside cards or sections. @@ -31,11 +29,9 @@ fun ItdaUnderlinedTextButton( Text( text = text, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 10.sp, - lineHeight = 10.sp, - textDecoration = TextDecoration.Underline + style = MaterialTheme.typography.labelSmall.copy( + textDecoration = TextDecoration.Underline + ) ) } } diff --git a/app/src/main/java/com/example/it_da/ui/component/section/HomeNotificationSection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeNotificationSection.kt similarity index 59% rename from app/src/main/java/com/example/it_da/ui/component/section/HomeNotificationSection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeNotificationSection.kt index 4536dba..6ddafc9 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/HomeNotificationSection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeNotificationSection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,13 +7,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.component.ItdaUnderlinedTextButton -import com.example.it_da.ui.component.card.HomeNotificationCard +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.commonComponent.ItdaUnderlinedTextButton +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.screen.home.component.card.HomeNotificationCard import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel +private val HomeNotificationCardSpacing = 13.dp + // Shows the notification summary section and a separate all-notifications action. @Composable fun HomeNotificationSection( @@ -23,15 +27,12 @@ fun HomeNotificationSection( modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { - ItdaSectionHeader( - title = "알림 요약ㆍ확인", - titleFontWeight = FontWeight.Medium - ) + ItdaSectionHeader(title = stringResource(id = R.string.home_notification_section_title)) - Spacer(modifier = Modifier.height(13.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.SectionHeaderContentSpacing)) Column( - verticalArrangement = Arrangement.spacedBy(13.dp), + verticalArrangement = Arrangement.spacedBy(HomeNotificationCardSpacing), modifier = Modifier.fillMaxWidth() ) { notifications.forEach { notification -> @@ -42,7 +43,7 @@ fun HomeNotificationSection( } ItdaUnderlinedTextButton( - text = "모든 알림 보기", + text = stringResource(id = R.string.home_notification_view_all), onClick = onViewAllClick ) } diff --git a/app/src/main/java/com/example/it_da/ui/component/section/HomeProfileSummarySection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeProfileSummarySection.kt similarity index 71% rename from app/src/main/java/com/example/it_da/ui/component/section/HomeProfileSummarySection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeProfileSummarySection.kt index b95f1bb..cdc0f08 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/HomeProfileSummarySection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeProfileSummarySection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.annotation.DrawableRes import androidx.compose.foundation.Image @@ -19,15 +19,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaCardDefaults import com.example.it_da.ui.screen.home.state.HomeProjectCountUiModel -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaPrimaryTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor +private val HomeProfileImageGreetingSpacing = 13.dp +private val HomeProfileGreetingDescriptionSpacing = 5.dp +private val HomeProfileCountCardSpacing = 18.dp +private val HomeProjectCountLabelValueSpacing = 17.dp + // Shows the user greeting and the project status count summary. @Composable fun HomeProfileSummarySection( @@ -44,36 +49,32 @@ fun HomeProfileSummarySection( ) { Image( painter = painterResource(id = profileImageResId), - contentDescription = "프로필 이미지", + contentDescription = stringResource(id = R.string.home_profile_image_description), modifier = Modifier.size(58.dp) ) - Spacer(modifier = Modifier.width(13.dp)) + Spacer(modifier = Modifier.width(HomeProfileImageGreetingSpacing)) Column { Text( - text = "안녕하세요, ${userName}님 👋", + text = stringResource(id = R.string.home_profile_greeting, userName), color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - lineHeight = 18.sp + style = MaterialTheme.typography.headlineLarge ) - Spacer(modifier = Modifier.height(5.dp)) + Spacer(modifier = Modifier.height(HomeProfileGreetingDescriptionSpacing)) Text( text = greetingDescription, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 15.sp + style = MaterialTheme.typography.bodySmall.copy( + lineHeight = 15.sp + ) ) } } - Spacer(modifier = Modifier.height(18.dp)) + Spacer(modifier = Modifier.height(HomeProfileCountCardSpacing)) HomeProjectCountCard(projectCount = projectCount) } @@ -105,15 +106,15 @@ private fun HomeProjectCountCard( verticalAlignment = Alignment.CenterVertically ) { HomeProjectCountItem( - label = "지원 중", + label = stringResource(id = R.string.home_project_count_applying), count = projectCount.applyingCount ) HomeProjectCountItem( - label = "참여 중", + label = stringResource(id = R.string.home_project_count_participating), count = projectCount.participatingCount ) HomeProjectCountItem( - label = "완료", + label = stringResource(id = R.string.home_project_count_completed), count = projectCount.completedCount ) } @@ -134,21 +135,15 @@ private fun HomeProjectCountItem( Text( text = label, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 13.sp, - lineHeight = 13.sp + style = MaterialTheme.typography.bodyMedium ) - Spacer(modifier = Modifier.height(17.dp)) + Spacer(modifier = Modifier.height(HomeProjectCountLabelValueSpacing)) Text( text = count.toString(), color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - lineHeight = 18.sp + style = MaterialTheme.typography.headlineLarge ) } } diff --git a/app/src/main/java/com/example/it_da/ui/component/section/ParticipatingProjectSection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/ParticipatingProjectSection.kt similarity index 63% rename from app/src/main/java/com/example/it_da/ui/component/section/ParticipatingProjectSection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/ParticipatingProjectSection.kt index 39be3c1..d34b663 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/ParticipatingProjectSection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/ParticipatingProjectSection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.component.card.ParticipatingProjectCard +import androidx.compose.ui.res.stringResource +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.screen.home.component.card.ParticipatingProjectCard import com.example.it_da.ui.screen.home.state.ParticipatingProjectUiModel // Shows the user's participating projects with a card type separate from recommendations. @@ -21,12 +23,14 @@ fun ParticipatingProjectSection( modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { - ItdaSectionHeader(title = "참여 중인 프로젝트") + ItdaSectionHeader( + title = stringResource(id = R.string.home_participating_project_section_title) + ) - Spacer(modifier = Modifier.height(13.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.SectionHeaderContentSpacing)) Column( - verticalArrangement = Arrangement.spacedBy(15.dp), + verticalArrangement = Arrangement.spacedBy(ItdaLayoutDefaults.ProjectCardSpacing), modifier = Modifier.fillMaxWidth() ) { projects.forEach { project -> diff --git a/app/src/main/java/com/example/it_da/ui/component/section/RecommendedProjectSection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/RecommendedProjectSection.kt similarity index 66% rename from app/src/main/java/com/example/it_da/ui/component/section/RecommendedProjectSection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/RecommendedProjectSection.kt index fb389bc..cf3582e 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/RecommendedProjectSection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/RecommendedProjectSection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.component.card.RecommendedProjectCard +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.screen.home.component.card.RecommendedProjectCard import com.example.it_da.ui.screen.home.state.RecommendedProjectUiModel // Shows the recommended project section with independently clickable project cards. @@ -23,14 +25,14 @@ fun RecommendedProjectSection( ) { Column(modifier = modifier.fillMaxWidth()) { ItdaSectionHeader( - title = "추천 프로젝트", + title = stringResource(id = R.string.home_recommended_project_section_title), titleFontWeight = FontWeight.Bold ) - Spacer(modifier = Modifier.height(13.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.SectionHeaderContentSpacing)) Column( - verticalArrangement = Arrangement.spacedBy(15.dp), + verticalArrangement = Arrangement.spacedBy(ItdaLayoutDefaults.ProjectCardSpacing), modifier = Modifier.fillMaxWidth() ) { projects.forEach { project -> diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt b/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt index e7e704f..26eb564 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt @@ -3,14 +3,15 @@ package com.example.it_da.ui.screen.home import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.example.it_da.ui.screen.home.viewmodel.HomeViewModel -import com.example.it_da.ui.screen.home.viewmodel.HomeViewModelFactory // Connects HomeViewModel state to the home screen and leaves future navigation targets as callbacks. @Composable fun HomeRoute( - viewModel: HomeViewModel = viewModel(factory = HomeViewModelFactory()) + onCreateProjectClick: () -> Unit = {}, + onNotificationClick: () -> Unit = {}, + viewModel: HomeViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -21,12 +22,12 @@ fun HomeRoute( onParticipatingProjectClick = {}, onParticipatingProjectDetailClick = {}, onNotificationClick = {}, - onViewAllNotificationsClick = {}, + onViewAllNotificationsClick = onNotificationClick, onExploreProjectsClick = {}, onHomeTabClick = {}, onExploreTabClick = {}, - onCreateProjectClick = {}, - onNotificationTabClick = {}, + onCreateProjectClick = onCreateProjectClick, + onNotificationTabClick = onNotificationClick, onProfileTabClick = {} ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt index ff9a4e6..a188ae3 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt @@ -1,12 +1,11 @@ package com.example.it_da.ui.screen.home import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding @@ -16,16 +15,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.it_da.R -import com.example.it_da.ui.screen.home.component.HomeBottomNavigationBar -import com.example.it_da.ui.component.section.HomeNotificationSection -import com.example.it_da.ui.component.section.HomeProfileSummarySection -import com.example.it_da.ui.component.section.ParticipatingProjectSection -import com.example.it_da.ui.component.section.RecommendedProjectSection -import com.example.it_da.ui.screen.signup.component.SignUpPrimaryButton -import com.example.it_da.ui.screen.signup.component.SignUpTopBar +import com.example.it_da.ui.commonComponent.ItdaBottomNavigationBar +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaPrimaryButton +import com.example.it_da.ui.commonComponent.ItdaTopBar +import com.example.it_da.ui.commonComponent.section.HomeNotificationSection +import com.example.it_da.ui.commonComponent.section.HomeProfileSummarySection +import com.example.it_da.ui.commonComponent.section.ParticipatingProjectSection +import com.example.it_da.ui.commonComponent.section.RecommendedProjectSection import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel import com.example.it_da.ui.screen.home.state.HomeProjectCountUiModel import com.example.it_da.ui.screen.home.state.HomeUiState @@ -59,7 +60,7 @@ fun HomeScreen( .statusBarsPadding() ) { Column(modifier = Modifier.fillMaxSize()) { - SignUpTopBar(title = "Home") + ItdaTopBar(title = stringResource(id = R.string.home_top_bar_title)) HomeContent( uiState = uiState, @@ -74,7 +75,7 @@ fun HomeScreen( ) } - HomeBottomNavigationBar( + ItdaBottomNavigationBar( onHomeClick = onHomeTabClick, onExploreClick = onExploreTabClick, onCreateProjectClick = onCreateProjectClick, @@ -105,10 +106,10 @@ private fun HomeContent( .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 38.dp) - .padding(bottom = 80.dp) + .padding(top = ItdaLayoutDefaults.LongVerticalSpacing) + .padding(bottom = ItdaLayoutDefaults.BottomNavigationContentPadding), + verticalArrangement = Arrangement.spacedBy(ItdaLayoutDefaults.LongVerticalSpacing) ) { - Spacer(modifier = Modifier.height(24.dp)) - HomeProfileSummarySection( profileImageResId = uiState.profileImageResId, userName = uiState.userName, @@ -116,36 +117,28 @@ private fun HomeContent( projectCount = uiState.projectCount ) - Spacer(modifier = Modifier.height(22.dp)) - RecommendedProjectSection( projects = uiState.recommendedProjects, onProjectClick = onRecommendedProjectClick, onDetailClick = onRecommendedProjectDetailClick ) - Spacer(modifier = Modifier.height(28.dp)) - ParticipatingProjectSection( projects = uiState.participatingProjects, onProjectClick = onParticipatingProjectClick, onDetailClick = onParticipatingProjectDetailClick ) - Spacer(modifier = Modifier.height(28.dp)) - HomeNotificationSection( notifications = uiState.notifications, onNotificationClick = onNotificationClick, onViewAllClick = onViewAllNotificationsClick ) - Spacer(modifier = Modifier.height(28.dp)) - - SignUpPrimaryButton( + ItdaPrimaryButton( enabled = true, onClick = onExploreProjectsClick, - text = "프로젝트 탐색하기", + text = stringResource(id = R.string.home_explore_projects_button), containerColor = ItdaHomeExploreButtonGray ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt b/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt index f448e46..5193789 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt @@ -2,11 +2,11 @@ package com.example.it_da.ui.screen.home import com.example.it_da.R import com.example.it_da.domain.model.HomeDashboard -import com.example.it_da.domain.model.HomeNotification -import com.example.it_da.domain.model.HomeNotificationType -import com.example.it_da.domain.model.HomeParticipatingProject -import com.example.it_da.domain.model.HomeProjectCount -import com.example.it_da.domain.model.HomeRecommendedProject +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectCount +import com.example.it_da.domain.model.RecommendedProject import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel import com.example.it_da.ui.screen.home.state.HomeProjectCountUiModel import com.example.it_da.ui.screen.home.state.HomeUiState @@ -33,7 +33,7 @@ fun HomeDashboard.toHomeUiState(): HomeUiState { } // Converts domain project count data into the count model used by the Home header. -private fun HomeProjectCount.toHomeProjectCountUiModel(): HomeProjectCountUiModel { +private fun ProjectCount.toHomeProjectCountUiModel(): HomeProjectCountUiModel { return HomeProjectCountUiModel( applyingCount = applyingCount, participatingCount = participatingCount, @@ -42,7 +42,7 @@ private fun HomeProjectCount.toHomeProjectCountUiModel(): HomeProjectCountUiMode } // Converts a domain recommended project into the card model used by the Home UI. -private fun HomeRecommendedProject.toRecommendedProjectUiModel(): RecommendedProjectUiModel { +private fun RecommendedProject.toRecommendedProjectUiModel(): RecommendedProjectUiModel { return RecommendedProjectUiModel( id = id, title = title, @@ -54,7 +54,7 @@ private fun HomeRecommendedProject.toRecommendedProjectUiModel(): RecommendedPro } // Converts a domain participating project into the card model used by the Home UI. -private fun HomeParticipatingProject.toParticipatingProjectUiModel(): ParticipatingProjectUiModel { +private fun ParticipatingProject.toParticipatingProjectUiModel(): ParticipatingProjectUiModel { return ParticipatingProjectUiModel( id = id, title = title, @@ -65,7 +65,7 @@ private fun HomeParticipatingProject.toParticipatingProjectUiModel(): Participat } // Converts a domain notification into the image-backed model used by the Home UI. -private fun HomeNotification.toHomeNotificationUiModel(): HomeNotificationUiModel { +private fun Notification.toHomeNotificationUiModel(): HomeNotificationUiModel { return HomeNotificationUiModel( id = id, imageResId = type.toNotificationImageResId(), @@ -76,17 +76,17 @@ private fun HomeNotification.toHomeNotificationUiModel(): HomeNotificationUiMode } // Maps notification type values to drawable resources owned by the UI layer. -private fun HomeNotificationType.toNotificationImageResId(): Int { +private fun NotificationType.toNotificationImageResId(): Int { return when (this) { - HomeNotificationType.MESSAGE -> R.drawable.home_notification_mailbox - HomeNotificationType.PROJECT_JOIN -> R.drawable.home_notification_laptop + NotificationType.MESSAGE -> R.drawable.home_notification_mailbox + NotificationType.PROJECT_JOIN -> R.drawable.home_notification_laptop } } // Maps notification type values to accessibility descriptions for notification images. -private fun HomeNotificationType.toNotificationImageDescription(): String { +private fun NotificationType.toNotificationImageDescription(): String { return when (this) { - HomeNotificationType.MESSAGE -> "새 메시지 알림" - HomeNotificationType.PROJECT_JOIN -> "프로젝트 참여 알림" + NotificationType.MESSAGE -> "새 메시지 알림" + NotificationType.PROJECT_JOIN -> "프로젝트 참여 알림" } } diff --git a/app/src/main/java/com/example/it_da/ui/component/card/HomeNotificationCard.kt b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/HomeNotificationCard.kt similarity index 70% rename from app/src/main/java/com/example/it_da/ui/component/card/HomeNotificationCard.kt rename to app/src/main/java/com/example/it_da/ui/screen/home/component/card/HomeNotificationCard.kt index de02b8b..233ce3a 100644 --- a/app/src/main/java/com/example/it_da/ui/component/card/HomeNotificationCard.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/HomeNotificationCard.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.card +package com.example.it_da.ui.screen.home.component.card import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -11,23 +11,21 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults +import com.example.it_da.ui.commonComponent.ItdaCard import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaSecondaryTextColor +private val HomeNotificationImageContentSpacing = 16.dp +private val HomeNotificationMessageTimeSpacing = 8.dp + // Displays one notification summary row with state-provided image, message, and elapsed time. @Composable fun HomeNotificationCard( @@ -35,16 +33,13 @@ fun HomeNotificationCard( onClick: (String) -> Unit, modifier: Modifier = Modifier ) { - Surface( + ItdaCard( modifier = modifier .fillMaxWidth() .heightIn(min = 54.dp) .clickable { onClick(notification.id) - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = ItdaCardDefaults.outlinedBorder() + } ) { Row( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), @@ -56,29 +51,23 @@ fun HomeNotificationCard( modifier = Modifier.size(31.dp) ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(HomeNotificationImageContentSpacing)) Column { Text( text = notification.message, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 12.sp, + style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(HomeNotificationMessageTimeSpacing)) Text( text = notification.elapsedTime, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 12.sp + style = MaterialTheme.typography.bodySmall ) } } diff --git a/app/src/main/java/com/example/it_da/ui/component/card/ParticipatingProjectCard.kt b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/ParticipatingProjectCard.kt similarity index 73% rename from app/src/main/java/com/example/it_da/ui/component/card/ParticipatingProjectCard.kt rename to app/src/main/java/com/example/it_da/ui/screen/home/component/card/ParticipatingProjectCard.kt index 420c7bd..039857a 100644 --- a/app/src/main/java/com/example/it_da/ui/component/card/ParticipatingProjectCard.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/ParticipatingProjectCard.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.card +package com.example.it_da.ui.screen.home.component.card import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -8,28 +8,28 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults -import com.example.it_da.ui.component.ItdaOutlinedBadge -import com.example.it_da.ui.component.ItdaUnderlinedTextButton +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaCard +import com.example.it_da.ui.commonComponent.ItdaOutlinedBadge +import com.example.it_da.ui.commonComponent.ItdaUnderlinedTextButton import com.example.it_da.ui.screen.home.component.HomeProjectContentEndPadding import com.example.it_da.ui.screen.home.component.HomeProjectContentStartPadding import com.example.it_da.ui.screen.home.component.HomeProjectTitleStartPadding import com.example.it_da.ui.screen.home.state.ParticipatingProjectUiModel -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaPrimaryTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor +private val ParticipatingProjectTitleRoleSpacing = 8.dp +private val ParticipatingProjectRoleTeamSpacing = 36.dp + // Displays one participating project with role and progress text supplied by state. @Composable fun ParticipatingProjectCard( @@ -38,16 +38,13 @@ fun ParticipatingProjectCard( onDetailClick: (String) -> Unit, modifier: Modifier = Modifier ) { - Surface( + ItdaCard( modifier = modifier .fillMaxWidth() .heightIn(min = 106.dp) .clickable { onProjectClick(project.id) - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = ItdaCardDefaults.outlinedBorder() + } ) { Column( modifier = Modifier.padding( @@ -67,10 +64,7 @@ fun ParticipatingProjectCard( Text( text = project.title, color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - lineHeight = 18.sp, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -79,15 +73,12 @@ fun ParticipatingProjectCard( ItdaOutlinedBadge(text = project.statusText) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(ParticipatingProjectTitleRoleSpacing)) Text( text = project.myRole, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 14.sp, + style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding( @@ -96,7 +87,7 @@ fun ParticipatingProjectCard( ) ) - Spacer(modifier = Modifier.height(36.dp)) + Spacer(modifier = Modifier.height(ParticipatingProjectRoleTeamSpacing)) Row( verticalAlignment = Alignment.CenterVertically, @@ -110,17 +101,14 @@ fun ParticipatingProjectCard( Text( text = project.teamSummary, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 11.sp, - lineHeight = 11.sp, + style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) ItdaUnderlinedTextButton( - text = project.detailText, + text = stringResource(id = R.string.common_view_details), onClick = { onDetailClick(project.id) } diff --git a/app/src/main/java/com/example/it_da/ui/component/card/RecommendedProjectCard.kt b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/RecommendedProjectCard.kt similarity index 76% rename from app/src/main/java/com/example/it_da/ui/component/card/RecommendedProjectCard.kt rename to app/src/main/java/com/example/it_da/ui/screen/home/component/card/RecommendedProjectCard.kt index 0116f88..7ed6830 100644 --- a/app/src/main/java/com/example/it_da/ui/component/card/RecommendedProjectCard.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/RecommendedProjectCard.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.card +package com.example.it_da.ui.screen.home.component.card import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -11,29 +11,31 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults -import com.example.it_da.ui.component.ItdaOutlinedBadge -import com.example.it_da.ui.component.ItdaUnderlinedTextButton +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaCard +import com.example.it_da.ui.commonComponent.ItdaOutlinedBadge +import com.example.it_da.ui.commonComponent.ItdaUnderlinedTextButton import com.example.it_da.ui.screen.home.component.HomeProjectContentEndPadding import com.example.it_da.ui.screen.home.component.HomeProjectContentStartPadding import com.example.it_da.ui.screen.home.component.HomeProjectTitleStartPadding import com.example.it_da.ui.screen.home.state.RecommendedProjectUiModel -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaPrimaryTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor +private val RecommendedProjectTitleRecruitSpacing = 8.dp +private val RecommendedProjectRecruitStackSpacing = 18.dp +private val RecommendedProjectTechStackSpacing = 6.dp +private val RecommendedProjectStackParticipantsSpacing = 13.dp + // Displays one recommended project with state-provided title, status, stack, and participant text. @Composable fun RecommendedProjectCard( @@ -42,16 +44,13 @@ fun RecommendedProjectCard( onDetailClick: (String) -> Unit, modifier: Modifier = Modifier ) { - Surface( + ItdaCard( modifier = modifier .fillMaxWidth() .heightIn(min = 118.dp) .clickable { onProjectClick(project.id) - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = ItdaCardDefaults.outlinedBorder() + } ) { Column( modifier = Modifier.padding( @@ -71,10 +70,7 @@ fun RecommendedProjectCard( Text( text = project.title, color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - lineHeight = 18.sp, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -83,15 +79,12 @@ fun RecommendedProjectCard( ItdaOutlinedBadge(text = project.statusText) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(RecommendedProjectTitleRecruitSpacing)) Text( text = project.recruitingSummary, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 14.sp, + style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding( @@ -100,10 +93,10 @@ fun RecommendedProjectCard( ) ) - Spacer(modifier = Modifier.height(18.dp)) + Spacer(modifier = Modifier.height(RecommendedProjectRecruitStackSpacing)) Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(RecommendedProjectTechStackSpacing), modifier = Modifier .fillMaxWidth() .padding( @@ -117,7 +110,7 @@ fun RecommendedProjectCard( } } - Spacer(modifier = Modifier.height(13.dp)) + Spacer(modifier = Modifier.height(RecommendedProjectStackParticipantsSpacing)) Row( verticalAlignment = Alignment.CenterVertically, @@ -136,17 +129,14 @@ fun RecommendedProjectCard( Text( text = project.participantSummary, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 11.sp, - lineHeight = 11.sp, + style = MaterialTheme.typography.labelMedium, maxLines = 1, softWrap = false ) } ItdaUnderlinedTextButton( - text = project.detailText, + text = stringResource(id = R.string.common_view_details), onClick = { onDetailClick(project.id) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt b/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt index 4828fdc..bb0d1f1 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt @@ -1,11 +1,10 @@ package com.example.it_da.ui.screen.home.state -// Represents a project the user is already participating in. +// Represents server-provided participating project values displayed by one project card. data class ParticipatingProjectUiModel( val id: String, val title: String, val myRole: String, val statusText: String, - val teamSummary: String, - val detailText: String = "자세히 보기" + val teamSummary: String ) diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt b/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt index 68ca42e..a236d1a 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt @@ -1,12 +1,11 @@ package com.example.it_da.ui.screen.home.state -// Represents a recommended project card whose visible text can come from remote data later. +// Represents server-provided recommended project values displayed by one project card. data class RecommendedProjectUiModel( val id: String, val title: String, val recruitingSummary: String, val statusText: String, val techStacks: List, - val participantSummary: String, - val detailText: String = "자세히 보기" + val participantSummary: String ) diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt b/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt index 5221627..af5fa48 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt @@ -5,27 +5,35 @@ import androidx.lifecycle.viewModelScope import com.example.it_da.data.repository.HomeRepository import com.example.it_da.ui.screen.home.toHomeUiState import com.example.it_da.ui.screen.home.state.HomeUiState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class HomeViewModel( +@HiltViewModel +class HomeViewModel @Inject constructor( private val homeRepository: HomeRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(HomeUiState()) - val uiState = _uiState.asStateFlow() + val uiState = homeRepository.homeDashboard + .map { homeDashboard -> + homeDashboard.toHomeUiState() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = HomeUiState() + ) init { - loadHomeDashboard() + refreshHomeDashboard() } - // Loads home dashboard values from the repository and exposes them as UI state. - private fun loadHomeDashboard() { + // Requests dashboard refresh while ongoing Store changes continue to update UI state. + private fun refreshHomeDashboard() { viewModelScope.launch { - homeRepository.getHomeDashboard() - .onSuccess { homeDashboard -> - _uiState.value = homeDashboard.toHomeUiState() - } + homeRepository.refreshDashboard() } } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelFactory.kt b/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelFactory.kt deleted file mode 100644 index eaae17a..0000000 --- a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.it_da.ui.screen.home.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.it_da.data.repository.FakeHomeRepository - -class HomeViewModelFactory : ViewModelProvider.Factory { - // Creates HomeViewModel with a fake repository until the server-backed repository is ready. - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { - return HomeViewModel(FakeHomeRepository()) as T - } - - throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } -} diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt b/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt index bcf68f9..bb99362 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt @@ -6,11 +6,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.example.it_da.ui.screen.login.screen.LoginScreen import com.example.it_da.ui.screen.login.state.LoginNavigationEffect import com.example.it_da.ui.screen.login.viewmodel.LoginViewModel -import com.example.it_da.ui.screen.login.viewmodel.LoginViewModelFactory // Connects login ViewModel state, social auth events, and navigation callbacks to the screen. @Composable @@ -18,10 +17,11 @@ fun LoginRoute( onSignUpClick: () -> Unit, onLoginSuccess: () -> Unit, onSocialSignUpSuccess: () -> Unit, - viewModel: LoginViewModel = viewModel(factory = LoginViewModelFactory()) + viewModel: LoginViewModel = hiltViewModel() ) { val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() + val loginErrorMessage = uiState.loginErrorMessage val socialAuthErrorMessage = uiState.socialAuthErrorMessage LaunchedEffect(viewModel) { @@ -33,6 +33,13 @@ fun LoginRoute( } } + LaunchedEffect(loginErrorMessage) { + if (loginErrorMessage != null) { + Toast.makeText(context, loginErrorMessage, Toast.LENGTH_SHORT).show() + viewModel.clearLoginError() + } + } + LaunchedEffect(socialAuthErrorMessage) { if (socialAuthErrorMessage != null) { Toast.makeText(context, socialAuthErrorMessage, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt index 76cdcfe..50efd0c 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt @@ -44,7 +44,7 @@ fun LoginButton( Box(contentAlignment = Alignment.Center) { Text( text = "로그인", - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.titleMedium ) } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt index cd6abb3..c359f57 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +private val LoginFieldSpacing = 20.dp + // Groups the id and password fields so their spacing stays consistent. @Composable fun LoginInputGroup( @@ -24,7 +26,7 @@ fun LoginInputGroup( placeholder = "아이디" ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(LoginFieldSpacing)) LoginUnderlineTextField( value = password, diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt index ae671cd..96f056d 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +private val LoginIntroTitleDescriptionSpacing = 28.dp + // Displays the main launch message as one grouped text element. @Composable fun LoginIntroTextGroup( @@ -33,7 +35,7 @@ fun LoginIntroTextGroup( textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(LoginIntroTitleDescriptionSpacing)) Text( text = "로그인 한 번으로 당신의 포트폴리오 첫 줄이 바뀝니다.\n퍼즐 조각처럼 딱 맞는 파트너를 만나는 곳,", diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt index ec0259e..a6a51e0 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.it_da.ui.theme.ItdaInputCursorColor import com.example.it_da.ui.theme.ItdaInputTextColor import com.example.it_da.ui.theme.ItdaInputUnderlineColor @@ -36,10 +35,8 @@ fun LoginUnderlineTextField( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val textStyle = MaterialTheme.typography.bodyLarge.copy( - color = ItdaInputTextColor, - fontSize = 16.sp, - lineHeight = 20.sp + val textStyle = MaterialTheme.typography.labelLarge.copy( + color = ItdaInputTextColor ) Column( diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt index 3dee7e4..f992aef 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.example.it_da.R +private val SocialLoginButtonSpacing = 12.dp + // Places the social login image buttons in the order shown by the design. @Composable fun SocialLoginButtonRow( @@ -22,7 +24,7 @@ fun SocialLoginButtonRow( ) { Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(SocialLoginButtonSpacing) ) { SocialLoginButton( imageResId = R.drawable.ic_apple_login, diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt index 9729869..e209b9d 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt @@ -28,6 +28,14 @@ import com.example.it_da.ui.screen.login.component.SocialLoginButtonRow import com.example.it_da.ui.screen.login.state.LoginUiState import com.example.it_da.ui.theme.ITDATheme +private val LoginTopSpacing = 86.dp +private val LoginLogoIntroSpacing = 46.dp +private val LoginIntroInputSpacing = 69.dp +private val LoginInputButtonSpacing = 35.dp +private val LoginButtonSignUpSpacing = 15.dp +private val LoginSignUpSocialSpacing = 85.dp +private val LoginSocialGuideSpacing = 29.dp + // Assembles the complete login screen from focused UI components. @Composable fun LoginScreen( @@ -50,7 +58,7 @@ fun LoginScreen( .padding(horizontal = 25.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(86.dp)) + Spacer(modifier = Modifier.height(LoginTopSpacing)) Image( painter = painterResource(id = R.drawable.itda_logo), @@ -58,11 +66,11 @@ fun LoginScreen( modifier = Modifier.size(80.dp) ) - Spacer(modifier = Modifier.height(46.dp)) + Spacer(modifier = Modifier.height(LoginLogoIntroSpacing)) LoginIntroTextGroup() - Spacer(modifier = Modifier.height(69.dp)) + Spacer(modifier = Modifier.height(LoginIntroInputSpacing)) LoginInputGroup( id = uiState.id, @@ -71,20 +79,20 @@ fun LoginScreen( onPasswordChange = onPasswordChange ) - Spacer(modifier = Modifier.height(35.dp)) + Spacer(modifier = Modifier.height(LoginInputButtonSpacing)) LoginButton( enabled = uiState.isLoginEnabled, onClick = onLoginClick ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(LoginButtonSignUpSpacing)) LoginSignUpGuide( onSignUpClick = onSignUpClick ) - Spacer(modifier = Modifier.height(85.dp)) + Spacer(modifier = Modifier.height(LoginSignUpSocialSpacing)) SocialLoginButtonRow( onAppleLoginClick = onAppleLoginClick, @@ -92,7 +100,7 @@ fun LoginScreen( onKakaoLoginClick = onKakaoLoginClick ) - Spacer(modifier = Modifier.height(29.dp)) + Spacer(modifier = Modifier.height(LoginSocialGuideSpacing)) LoginBottomGuideText() } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt b/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt index 2a06878..44c5993 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt @@ -4,9 +4,11 @@ package com.example.it_da.ui.screen.login.state data class LoginUiState( val id: String = "", val password: String = "", + val isLoginLoading: Boolean = false, + val loginErrorMessage: String? = null, val isSocialAuthLoading: Boolean = false, val socialAuthErrorMessage: String? = null ) { val isLoginEnabled: Boolean - get() = id.isNotBlank() && password.isNotBlank() + get() = id.isNotBlank() && password.isNotBlank() && !isLoginLoading } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt b/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt index c890337..26fb009 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt @@ -4,10 +4,13 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.it_da.data.auth.SocialAuthSessionStore +import com.example.it_da.data.repository.AuthSessionRepository import com.example.it_da.data.repository.SocialAuthRepository import com.example.it_da.domain.model.SocialAuthProvider import com.example.it_da.ui.screen.login.state.LoginNavigationEffect import com.example.it_da.ui.screen.login.state.LoginUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -15,9 +18,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class LoginViewModel( +@HiltViewModel +class LoginViewModel @Inject constructor( private val socialAuthRepository: SocialAuthRepository, - private val socialAuthSessionStore: SocialAuthSessionStore = SocialAuthSessionStore.default + private val socialAuthSessionStore: SocialAuthSessionStore, + private val authSessionRepository: AuthSessionRepository ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState = _uiState.asStateFlow() @@ -39,14 +44,42 @@ class LoginViewModel( } } - // Emits the normal login navigation event until server login validation is connected. + // Saves a temporary local session and navigates home until server login validation is connected. fun onLoginClick() { if (!_uiState.value.isLoginEnabled) { return } viewModelScope.launch { - _navigationEffect.emit(LoginNavigationEffect.NavigateToHome) + _uiState.update { currentState -> + currentState.copy( + isLoginLoading = true, + loginErrorMessage = null + ) + } + + authSessionRepository.saveTemporaryToken() + .onSuccess { + _uiState.update { currentState -> + currentState.copy(isLoginLoading = false) + } + _navigationEffect.emit(LoginNavigationEffect.NavigateToHome) + } + .onFailure { throwable -> + _uiState.update { currentState -> + currentState.copy( + isLoginLoading = false, + loginErrorMessage = throwable.message ?: "로그인 상태 저장에 실패했습니다." + ) + } + } + } + } + + // Clears the normal login error after the UI has shown it to the user. + fun clearLoginError() { + _uiState.update { currentState -> + currentState.copy(loginErrorMessage = null) } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelFactory.kt b/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelFactory.kt deleted file mode 100644 index 7cf7c85..0000000 --- a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelFactory.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.it_da.ui.screen.login.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.it_da.BuildConfig -import com.example.it_da.data.auth.GoogleSocialAuthClient -import com.example.it_da.data.auth.KakaoSocialAuthClient -import com.example.it_da.data.repository.DefaultSocialAuthRepository - -class LoginViewModelFactory : ViewModelProvider.Factory { - // Creates LoginViewModel with provider-specific SDK clients hidden behind the repository. - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(LoginViewModel::class.java)) { - val socialAuthRepository = DefaultSocialAuthRepository( - socialAuthClients = listOf( - GoogleSocialAuthClient(BuildConfig.GOOGLE_WEB_CLIENT_ID), - KakaoSocialAuthClient(BuildConfig.KAKAO_NATIVE_APP_KEY) - ) - ) - - return LoginViewModel(socialAuthRepository) as T - } - - throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } -} diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt index 7bc4a3c..8dae0d3 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt @@ -13,20 +13,22 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.screen.signup.component.SignUpOutlinedTextField -import com.example.it_da.ui.screen.signup.component.SignUpPrimaryButton -import com.example.it_da.ui.screen.signup.component.SignUpTopBar +import com.example.it_da.ui.commonComponent.ItdaOutlinedTextField +import com.example.it_da.ui.commonComponent.ItdaPrimaryButton +import com.example.it_da.ui.commonComponent.ItdaTopBar import com.example.it_da.ui.screen.signup.state.SignUpAccountUiState import com.example.it_da.ui.theme.ITDATheme import com.example.it_da.ui.theme.ItdaSecondaryTextColor -private val SignUpSectionTitleWeight = FontWeight.Medium -private val SignUpDescriptionWeight = FontWeight.Normal +private val SignUpAccountTopSpacing = 37.dp +private val SignUpAccountTitleDescriptionSpacing = 16.dp +private val SignUpAccountDescriptionFieldSpacing = 29.dp +private val SignUpAccountFieldSpacing = 21.dp +private val SignUpAccountBottomSpacing = 45.dp +private const val SignUpAccountFlexibleSpacingWeight = 1f // Assembles the first sign-up step from focused form components. @Composable @@ -45,7 +47,7 @@ fun SignUpAccountScreen( .statusBarsPadding() .navigationBarsPadding() ) { - SignUpTopBar(title = "회원 가입") + ItdaTopBar(title = "회원 가입") Column( modifier = Modifier @@ -53,42 +55,34 @@ fun SignUpAccountScreen( .weight(1f) .padding(horizontal = 32.dp) ) { - Spacer(modifier = Modifier.height(37.dp)) + Spacer(modifier = Modifier.height(SignUpAccountTopSpacing)) Text( text = "계정 만들기", color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = SignUpSectionTitleWeight, - fontSize = 21.sp, - lineHeight = 21.sp - ) + style = MaterialTheme.typography.titleLarge ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(SignUpAccountTitleDescriptionSpacing)) Text( text = "서비스를 이용하기 위해 기본 정보를 입력해 주세요", color = ItdaSecondaryTextColor, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = SignUpDescriptionWeight, - fontSize = 13.sp, - lineHeight = 13.sp - ) + style = MaterialTheme.typography.bodyMedium ) - Spacer(modifier = Modifier.height(29.dp)) + Spacer(modifier = Modifier.height(SignUpAccountDescriptionFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "아이디", value = uiState.id, onValueChange = onIdChange, placeholder = "6~15글자" ) - Spacer(modifier = Modifier.height(21.dp)) + Spacer(modifier = Modifier.height(SignUpAccountFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "비밀번호", value = uiState.password, onValueChange = onPasswordChange, @@ -96,9 +90,9 @@ fun SignUpAccountScreen( visualTransformation = PasswordVisualTransformation() ) - Spacer(modifier = Modifier.height(21.dp)) + Spacer(modifier = Modifier.height(SignUpAccountFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "비밀번호 확인", value = uiState.passwordConfirm, onValueChange = onPasswordConfirmChange, @@ -106,14 +100,14 @@ fun SignUpAccountScreen( visualTransformation = PasswordVisualTransformation() ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(SignUpAccountFlexibleSpacingWeight)) - SignUpPrimaryButton( + ItdaPrimaryButton( enabled = uiState.isNextEnabled, onClick = onNextClick ) - Spacer(modifier = Modifier.height(45.dp)) + Spacer(modifier = Modifier.height(SignUpAccountBottomSpacing)) } } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt index 73344d2..66bd4fb 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt @@ -16,14 +16,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.screen.signup.component.SignUpDropdownTextField -import com.example.it_da.ui.screen.signup.component.SignUpOutlinedTextField -import com.example.it_da.ui.screen.signup.component.SignUpPrimaryButton -import com.example.it_da.ui.screen.signup.component.SignUpTopBar +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.commonComponent.ItdaDropdownTextField +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaOutlinedTextField +import com.example.it_da.ui.commonComponent.ItdaPrimaryButton +import com.example.it_da.ui.commonComponent.ItdaTopBar import com.example.it_da.ui.screen.signup.state.SignUpAdditionalInfoUiState import com.example.it_da.ui.theme.ITDATheme +private val SignUpAdditionalTopSpacing = 31.dp +private val SignUpAdditionalHeaderFieldSpacing = 23.dp +private val SignUpAdditionalButtonSpacing = 120.dp // Assembles the second sign-up step for additional matching information. @Composable @@ -45,7 +49,7 @@ fun SignUpAdditionalInfoScreen( .statusBarsPadding() .navigationBarsPadding() ) { - SignUpTopBar(title = "추가 정보 입력") + ItdaTopBar(title = "추가 정보 입력") Column( modifier = Modifier @@ -53,25 +57,25 @@ fun SignUpAdditionalInfoScreen( .verticalScroll(rememberScrollState()) .padding(horizontal = 30.dp) ) { - Spacer(modifier = Modifier.height(31.dp)) + Spacer(modifier = Modifier.height(SignUpAdditionalTopSpacing)) ItdaSectionHeader( title = "추가 정보 입력", description = "매칭 품질을 높이기 위해 몇 가지 정보를 입력해 주세요" ) - Spacer(modifier = Modifier.height(23.dp)) + Spacer(modifier = Modifier.height(SignUpAdditionalHeaderFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "이름", value = uiState.name, onValueChange = onNameChange, placeholder = "예: 홍길동, 가나다, 하지와레" ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpDropdownTextField( + ItdaDropdownTextField( label = "관심 분야", value = uiState.interestField, onValueChange = onInterestFieldChange, @@ -79,18 +83,18 @@ fun SignUpAdditionalInfoScreen( onArrowClick = onDropdownArrowClick ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "기술 스택", value = uiState.techStack, onValueChange = onTechStackChange, placeholder = "예: Python, Figma, Swift" ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpDropdownTextField( + ItdaDropdownTextField( label = "기수", value = uiState.cohort, onValueChange = onCohortChange, @@ -98,9 +102,9 @@ fun SignUpAdditionalInfoScreen( onArrowClick = onDropdownArrowClick ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpDropdownTextField( + ItdaDropdownTextField( label = "학과", value = uiState.department, onValueChange = onDepartmentChange, @@ -108,9 +112,9 @@ fun SignUpAdditionalInfoScreen( onArrowClick = onDropdownArrowClick ) - Spacer(modifier = Modifier.height(120.dp)) + Spacer(modifier = Modifier.height(SignUpAdditionalButtonSpacing)) - SignUpPrimaryButton( + ItdaPrimaryButton( enabled = uiState.isNextEnabled, onClick = onNextClick ) diff --git a/app/src/main/java/com/example/it_da/ui/theme/Color.kt b/app/src/main/java/com/example/it_da/ui/theme/Color.kt index 9ee5ab0..cd06cfa 100644 --- a/app/src/main/java/com/example/it_da/ui/theme/Color.kt +++ b/app/src/main/java/com/example/it_da/ui/theme/Color.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color val ItdaWhite = Color(0xFFFFFFFF) val ItdaBlack = Color(0xFF000000) val ItdaTitleGray = Color(0xFF525252) +val ItdaSectionTextColor = Color(0xFF4F4F4F) val ItdaGuideGray = Color(0xFF8C8C8C) val ItdaInputBorderGray = Color(0xFF8C8C8C) val ItdaLineGray = Color(0x80545454) diff --git a/app/src/main/java/com/example/it_da/ui/theme/Font.kt b/app/src/main/java/com/example/it_da/ui/theme/Font.kt index e4e35cf..b8f7b05 100644 --- a/app/src/main/java/com/example/it_da/ui/theme/Font.kt +++ b/app/src/main/java/com/example/it_da/ui/theme/Font.kt @@ -20,15 +20,11 @@ val DotSans = FontFamily( ), Font( resId = R.font.dot_sans_medium, - weight = FontWeight(590) - ), - Font( - resId = R.font.dot_sans_medium, - weight = FontWeight(600) + weight = FontWeight.SemiBold ), Font( resId = R.font.dot_sans_bold, - weight = FontWeight(700) + weight = FontWeight.Bold ), Font( resId = R.font.dot_sans_extra_bold, diff --git a/app/src/main/java/com/example/it_da/ui/theme/Type.kt b/app/src/main/java/com/example/it_da/ui/theme/Type.kt index 8720b7f..d4d65f5 100644 --- a/app/src/main/java/com/example/it_da/ui/theme/Type.kt +++ b/app/src/main/java/com/example/it_da/ui/theme/Type.kt @@ -5,26 +5,76 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +private fun dotSansTextStyle( + fontWeight: FontWeight, + fontSize: Int +) = TextStyle( + fontFamily = DotSans, + fontWeight = fontWeight, + fontSize = fontSize.sp, + lineHeight = fontSize.sp, + letterSpacing = 0.sp +) + val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 16.sp, - letterSpacing = 0.sp + displayLarge = dotSansTextStyle( + fontWeight = FontWeight.Bold, + fontSize = 23 + ), + displayMedium = dotSansTextStyle( + fontWeight = FontWeight.Bold, + fontSize = 21 ), - titleLarge = TextStyle( - fontFamily = DotSans, + displaySmall = dotSansTextStyle( fontWeight = FontWeight.Bold, - fontSize = 21.sp, - lineHeight = 21.sp, - letterSpacing = 0.sp + fontSize = 20 + ), + headlineLarge = dotSansTextStyle( + fontWeight = FontWeight.Bold, + fontSize = 18 + ), + headlineMedium = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 19 + ), + headlineSmall = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 12 + ), + titleLarge = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 21 + ), + titleMedium = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16 + ), + titleSmall = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 13 + ), + bodyLarge = dotSansTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 15 + ), + bodyMedium = dotSansTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 13 + ), + bodySmall = dotSansTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12 + ), + labelLarge = dotSansTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 15 + ), + labelMedium = dotSansTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11 ), - bodyMedium = TextStyle( - fontFamily = DotSans, + labelSmall = dotSansTextStyle( fontWeight = FontWeight.Normal, - fontSize = 13.sp, - lineHeight = 13.sp, - letterSpacing = 0.sp + fontSize = 10 ) ) diff --git a/app/src/main/res/drawable/backbutton.png b/app/src/main/res/drawable/backbutton.png new file mode 100644 index 0000000..c89642b Binary files /dev/null and b/app/src/main/res/drawable/backbutton.png differ diff --git a/app/src/main/res/drawable/check.png b/app/src/main/res/drawable/check.png new file mode 100644 index 0000000..e5b5ba4 Binary files /dev/null and b/app/src/main/res/drawable/check.png differ diff --git a/app/src/main/res/drawable/not_check.png b/app/src/main/res/drawable/not_check.png new file mode 100644 index 0000000..add0484 Binary files /dev/null and b/app/src/main/res/drawable/not_check.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2e7033..c993426 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,67 @@ IT-DA - \ No newline at end of file + 다음으로 + 선택 목록 열기 + 자세히 보기 + Home + 프로젝트 탐색하기 + 추천 프로젝트 + 참여 중인 프로젝트 + 알림 요약ㆍ확인 + 모든 알림 보기 + 프로필 이미지 + 안녕하세요, %1$s님 👋 + 지원 중 + 참여 중 + 완료 + + 탐색 + 알림 + 프로필 + 프로젝트 추가 + Team create + 새 프로젝트 등록 + 기본 정보 + 설명 및 목표 + 모집 정보 + 프로젝트 이름 + 프로젝트 이름 + 카테고리 + 앱 개발 + 진행 기간 + 2개월 이내 + 진행 방식 + 온라인 + 프로젝트 소개 + 프로젝트 소개 + 목표 및 기대 결과 + 목표 및 기대 결과 + 모집 인원 + 1명 + 모집 역할 + 프론트엔드 + 필요 기술 스택 + 예: Kotlin, Figma + 지원 마감일 + 예: 2026-06-30 + 등록하기 + 뒤로가기 + 알림 + 알림 화면 뒤로가기 + 전체 읽음 처리 + 알림 유형 + 전체 + 안 읽음 + 읽음 + 알림 유형 선택 열기 + 새 프로젝트 추천 + 지원 결과 도착 + 팀 멤버 합류 + 나의 기술 스택과 일치하는 프로그램이 등록되었습니다. + 백엔드 개발자 1명이 팀에 합류했습니다. + 관심 분야 추천 프로젝트가 새로 업데이트 되었습니다. + 5분 전 + 1시간 전 + 읽음 + 안 읽음 + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..54dbc25 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,8 +6,9 @@ See https://developer.android.com/about/versions/12/backup-restore --> + - \ No newline at end of file + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..0bdd38e 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,15 +5,13 @@ --> + - - \ No newline at end of file + diff --git a/app/src/test/java/com/example/it_da/data/repository/DefaultAuthSessionRepositoryTest.kt b/app/src/test/java/com/example/it_da/data/repository/DefaultAuthSessionRepositoryTest.kt new file mode 100644 index 0000000..5c1c16c --- /dev/null +++ b/app/src/test/java/com/example/it_da/data/repository/DefaultAuthSessionRepositoryTest.kt @@ -0,0 +1,77 @@ +package com.example.it_da.data.repository + +import com.example.it_da.data.local.AuthTokenLocalDataSource +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DefaultAuthSessionRepositoryTest { + @Test + fun hasStoredTokenReturnsTrueWhenLocalTokenIsNotBlank() = runTest { + val repository = DefaultAuthSessionRepository( + FakeAuthTokenLocalDataSource(storedToken = "stored-token") + ) + + assertTrue(repository.hasStoredToken().getOrThrow()) + } + + @Test + fun hasStoredTokenReturnsFalseWhenLocalTokenIsBlank() = runTest { + val repository = DefaultAuthSessionRepository( + FakeAuthTokenLocalDataSource(storedToken = "") + ) + + assertFalse(repository.hasStoredToken().getOrThrow()) + } + + @Test + fun saveTemporaryTokenStoresNonBlankPlaceholderToken() = runTest { + val localDataSource = FakeAuthTokenLocalDataSource() + val repository = DefaultAuthSessionRepository(localDataSource) + + repository.saveTemporaryToken().getOrThrow() + + assertTrue(localDataSource.storedToken?.isNotBlank() == true) + } + + @Test + fun clearTokenRemovesStoredToken() = runTest { + val localDataSource = FakeAuthTokenLocalDataSource(storedToken = "stored-token") + val repository = DefaultAuthSessionRepository(localDataSource) + + repository.clearToken().getOrThrow() + + assertEquals(null, localDataSource.storedToken) + } + + @Test + fun localStorageFailureIsReturnedToCaller() = runTest { + val repository = DefaultAuthSessionRepository( + FakeAuthTokenLocalDataSource(readFailure = IllegalStateException("Read failed")) + ) + + assertEquals("Read failed", repository.hasStoredToken().exceptionOrNull()?.message) + } + + private class FakeAuthTokenLocalDataSource( + var storedToken: String? = null, + private val readFailure: Throwable? = null + ) : AuthTokenLocalDataSource { + override suspend fun getToken(): String? { + readFailure?.let { throwable -> + throw throwable + } + return storedToken + } + + override suspend fun saveToken(token: String) { + storedToken = token + } + + override suspend fun clearToken() { + storedToken = null + } + } +} diff --git a/app/src/test/java/com/example/it_da/data/store/DefaultNotificationStoreTest.kt b/app/src/test/java/com/example/it_da/data/store/DefaultNotificationStoreTest.kt new file mode 100644 index 0000000..166411a --- /dev/null +++ b/app/src/test/java/com/example/it_da/data/store/DefaultNotificationStoreTest.kt @@ -0,0 +1,50 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DefaultNotificationStoreTest { + @Test + fun markAsReadUpdatesOnlySelectedNotification() { + val store = createNotificationStore() + + store.markAsRead("first") + + assertTrue(store.notifications.value.first { it.id == "first" }.isRead) + assertFalse(store.notifications.value.first { it.id == "second" }.isRead) + } + + @Test + fun markAllAsReadUpdatesEveryNotification() { + val store = createNotificationStore() + + store.markAllAsRead() + + assertTrue(store.notifications.value.all(Notification::isRead)) + } + + private fun createNotificationStore(): DefaultNotificationStore { + return DefaultNotificationStore().apply { + replaceNotifications( + listOf( + createNotification("first"), + createNotification("second") + ) + ) + } + } + + private fun createNotification(id: String): Notification { + return Notification( + id = id, + type = NotificationType.MESSAGE, + title = "알림 제목", + message = "알림 내용", + elapsedTime = "방금 전", + isRead = false + ) + } +} diff --git a/app/src/test/java/com/example/it_da/data/store/DefaultProjectStoreTest.kt b/app/src/test/java/com/example/it_da/data/store/DefaultProjectStoreTest.kt new file mode 100644 index 0000000..5db1e7f --- /dev/null +++ b/app/src/test/java/com/example/it_da/data/store/DefaultProjectStoreTest.kt @@ -0,0 +1,49 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectCount +import com.example.it_da.domain.model.ProjectStoreState +import org.junit.Assert.assertEquals +import org.junit.Test + +class DefaultProjectStoreTest { + @Test + fun addCreatedProjectPrependsProjectAndIncrementsParticipatingCount() { + val store = DefaultProjectStore() + store.replaceState( + ProjectStoreState( + projectCount = ProjectCount( + applyingCount = 3, + participatingCount = 1, + completedCount = 1 + ), + participatingProjects = listOf( + createParticipatingProject(id = "existing", title = "기존 프로젝트") + ) + ) + ) + + store.addCreatedProject( + createParticipatingProject(id = "created", title = "새 프로젝트") + ) + + assertEquals(2, store.state.value.projectCount.participatingCount) + assertEquals( + listOf("created", "existing"), + store.state.value.participatingProjects.map(ParticipatingProject::id) + ) + } + + private fun createParticipatingProject( + id: String, + title: String + ): ParticipatingProject { + return ParticipatingProject( + id = id, + title = title, + myRole = "내 역할", + statusText = "진행 중", + teamSummary = "팀 정보" + ) + } +} diff --git a/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt b/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt index e28b488..6e1a5ea 100644 --- a/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt +++ b/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt @@ -1,17 +1,18 @@ package com.example.it_da.ui.screen.home.viewmodel -import com.example.it_da.data.repository.HomeRepository -import com.example.it_da.domain.model.HomeDashboard -import com.example.it_da.domain.model.HomeNotification -import com.example.it_da.domain.model.HomeNotificationType -import com.example.it_da.domain.model.HomeParticipatingProject -import com.example.it_da.domain.model.HomeProjectCount -import com.example.it_da.domain.model.HomeRecommendedProject +import com.example.it_da.data.repository.FakeHomeRepository +import com.example.it_da.data.store.DefaultNotificationStore +import com.example.it_da.data.store.DefaultProjectStore +import com.example.it_da.data.store.DefaultUserStore +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import com.example.it_da.domain.model.ParticipatingProject import com.example.it_da.testing.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -21,67 +22,123 @@ class HomeViewModelTest { val mainDispatcherRule = MainDispatcherRule() @Test - fun loadsHomeDashboardFromRepositoryIntoUiState() = runTest( + fun refreshesDashboardAndMapsSharedStoreValuesIntoUiState() = runTest( mainDispatcherRule.testDispatcher ) { - val homeDashboard = HomeDashboard( - userName = "서버사용자", - greetingDescription = "서버에서 받은 소개 문구", - projectCount = HomeProjectCount( - applyingCount = 7, - participatingCount = 2, - completedCount = 4 - ), - recommendedProjects = listOf( - HomeRecommendedProject( - id = "server-recommended", - title = "서버 추천 프로젝트", - recruitingSummary = "서버 모집 요약", - statusText = "서버 상태", - techStacks = listOf("iOS", "Design", "Back-end"), - participantSummary = "서버 참여 요약" - ) - ), - participatingProjects = listOf( - HomeParticipatingProject( - id = "server-participating", - title = "서버 참여 프로젝트", - myRole = "서버 역할", - statusText = "서버 진행 상태", - teamSummary = "서버 팀 요약" - ) - ), - notifications = listOf( - HomeNotification( - id = "server-notification", - type = HomeNotificationType.MESSAGE, - message = "서버 알림", - elapsedTime = "방금 전" - ) + val viewModel = createHomeViewModel() + + advanceUntilIdle() + + val uiState = viewModel.uiState.value + assertEquals("000", uiState.userName) + assertEquals(3, uiState.projectCount.applyingCount) + assertEquals("AI 기반 학습 플래너 [0부0부]", uiState.recommendedProjects.first().title) + assertEquals("사랑을 이어주는 앱 [달발]", uiState.participatingProjects.first().title) + assertEquals( + listOf("new-project-recommendation", "application-result"), + uiState.notifications.map { notification -> notification.id } + ) + } + + @Test + fun updatesUiStateWhenProjectStorePublishesCreatedProject() = runTest( + mainDispatcherRule.testDispatcher + ) { + val projectStore = DefaultProjectStore() + val viewModel = createHomeViewModel(projectStore = projectStore) + advanceUntilIdle() + + projectStore.addCreatedProject( + ParticipatingProject( + id = "created-project", + title = "새 프로젝트", + myRole = "내 역할 : 프로젝트 생성자", + statusText = "모집 중", + teamSummary = "팀원 1명ㆍ마감 2026-06-30" ) ) - val viewModel = HomeViewModel(StaticHomeRepository(homeDashboard)) + advanceUntilIdle() + assertEquals(2, viewModel.uiState.value.projectCount.participatingCount) + assertEquals("새 프로젝트", viewModel.uiState.value.participatingProjects.first().title) + } + + @Test + fun refreshDoesNotOverwriteStoreChangesAfterInitialLoad() = runTest( + mainDispatcherRule.testDispatcher + ) { + val projectStore = DefaultProjectStore() + val notificationStore = DefaultNotificationStore() + val repository = FakeHomeRepository( + userStore = DefaultUserStore(), + projectStore = projectStore, + notificationStore = notificationStore + ) + val viewModel = HomeViewModel(repository) advanceUntilIdle() - val uiState = viewModel.uiState.value - assertEquals("서버사용자", uiState.userName) - assertEquals("서버에서 받은 소개 문구", uiState.greetingDescription) - assertEquals(7, uiState.projectCount.applyingCount) - assertEquals("서버 추천 프로젝트", uiState.recommendedProjects.first().title) - assertEquals(listOf("iOS", "Design", "Back-end"), uiState.recommendedProjects.first().techStacks) - assertEquals("서버 참여 프로젝트", uiState.participatingProjects.first().title) - assertEquals("서버 역할", uiState.participatingProjects.first().myRole) - assertEquals("서버 팀 요약", uiState.participatingProjects.first().teamSummary) - assertEquals("서버 알림", uiState.notifications.first().message) + projectStore.addCreatedProject( + ParticipatingProject( + id = "created-project", + title = "새 프로젝트", + myRole = "내 역할 : 프로젝트 생성자", + statusText = "모집 중", + teamSummary = "팀원 1명ㆍ마감 2026-06-30" + ) + ) + notificationStore.markAllAsRead() + + repository.refreshDashboard() + advanceUntilIdle() + + assertEquals("새 프로젝트", viewModel.uiState.value.participatingProjects.first().title) + assertTrue(notificationStore.notifications.value.all(Notification::isRead)) } - private class StaticHomeRepository( - private val homeDashboard: HomeDashboard - ) : HomeRepository { - // Returns a fixed dashboard so the ViewModel mapping can be verified deterministically. - override suspend fun getHomeDashboard(): Result { - return Result.success(homeDashboard) - } + @Test + fun limitsHomeNotificationSummaryToLatestTwoSharedNotifications() = runTest( + mainDispatcherRule.testDispatcher + ) { + val notificationStore = DefaultNotificationStore() + val viewModel = createHomeViewModel(notificationStore = notificationStore) + advanceUntilIdle() + + notificationStore.replaceNotifications( + listOf( + createNotification("latest"), + createNotification("second"), + createNotification("third") + ) + ) + advanceUntilIdle() + + assertEquals( + listOf("latest", "second"), + viewModel.uiState.value.notifications.map { notification -> notification.id } + ) + } + + private fun createHomeViewModel( + projectStore: DefaultProjectStore = DefaultProjectStore(), + notificationStore: DefaultNotificationStore = DefaultNotificationStore() + ): HomeViewModel { + return HomeViewModel( + FakeHomeRepository( + userStore = DefaultUserStore(), + projectStore = projectStore, + notificationStore = notificationStore + ) + ) + } + + private fun createNotification(id: String): Notification { + return Notification( + id = id, + type = NotificationType.MESSAGE, + title = "알림 제목", + message = "알림 내용", + elapsedTime = "방금 전", + isRead = false + ) } } diff --git a/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt b/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt index 112005a..f63bc93 100644 --- a/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt +++ b/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt @@ -3,6 +3,7 @@ package com.example.it_da.ui.screen.login.viewmodel import android.content.Context import android.content.ContextWrapper import com.example.it_da.data.auth.SocialAuthSessionStore +import com.example.it_da.data.repository.AuthSessionRepository import com.example.it_da.data.repository.SocialAuthRepository import com.example.it_da.domain.model.SocialAuthAccount import com.example.it_da.domain.model.SocialAuthProvider @@ -41,7 +42,7 @@ class LoginViewModelTest { ) val repository = DeferredSocialAuthRepository() val sessionStore = SocialAuthSessionStore() - val viewModel = LoginViewModel(repository, sessionStore) + val viewModel = LoginViewModel(repository, sessionStore, FakeAuthSessionRepository()) val navigationEffect = async { viewModel.navigationEffect.first() } @@ -77,7 +78,7 @@ class LoginViewModelTest { ) val repository = DeferredSocialAuthRepository() val sessionStore = SocialAuthSessionStore() - val viewModel = LoginViewModel(repository, sessionStore) + val viewModel = LoginViewModel(repository, sessionStore, FakeAuthSessionRepository()) val navigationEffect = async { viewModel.navigationEffect.first() } @@ -102,7 +103,7 @@ class LoginViewModelTest { ) { val repository = DeferredSocialAuthRepository() val sessionStore = SocialAuthSessionStore() - val viewModel = LoginViewModel(repository, sessionStore) + val viewModel = LoginViewModel(repository, sessionStore, FakeAuthSessionRepository()) viewModel.onGoogleSignUpClick(context) runCurrent() @@ -119,7 +120,11 @@ class LoginViewModelTest { mainDispatcherRule.testDispatcher ) { val repository = DeferredSocialAuthRepository() - val viewModel = LoginViewModel(repository, SocialAuthSessionStore()) + val viewModel = LoginViewModel( + repository, + SocialAuthSessionStore(), + FakeAuthSessionRepository() + ) viewModel.onAppleSignUpClick() @@ -133,7 +138,12 @@ class LoginViewModelTest { mainDispatcherRule.testDispatcher ) { val repository = DeferredSocialAuthRepository() - val viewModel = LoginViewModel(repository, SocialAuthSessionStore()) + val authSessionRepository = FakeAuthSessionRepository() + val viewModel = LoginViewModel( + repository, + SocialAuthSessionStore(), + authSessionRepository + ) val navigationEffect = async { viewModel.navigationEffect.first() } @@ -148,6 +158,30 @@ class LoginViewModelTest { navigationEffect.await() ) assertNull(repository.requestedProvider) + assertEquals(1, authSessionRepository.saveRequestCount) + } + + @Test + fun normalLoginStaysOnLoginAndShowsErrorWhenSessionSaveFails() = runTest( + mainDispatcherRule.testDispatcher + ) { + val authSessionRepository = FakeAuthSessionRepository( + saveResult = Result.failure(IllegalStateException("Session save failed")) + ) + val viewModel = LoginViewModel( + DeferredSocialAuthRepository(), + SocialAuthSessionStore(), + authSessionRepository + ) + + viewModel.onIdChange("itda-user") + viewModel.onPasswordChange("password") + viewModel.onLoginClick() + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoginLoading) + assertEquals("Session save failed", viewModel.uiState.value.loginErrorMessage) + assertEquals(1, authSessionRepository.saveRequestCount) } private class DeferredSocialAuthRepository : SocialAuthRepository { @@ -169,4 +203,25 @@ class LoginViewModelTest { result.complete(authResult) } } + + private class FakeAuthSessionRepository( + private val saveResult: Result = Result.success(Unit) + ) : AuthSessionRepository { + var saveRequestCount: Int = 0 + private set + + override suspend fun hasStoredToken(): Result { + return Result.success(false) + } + + // Records the temporary token request and returns the configured storage result. + override suspend fun saveTemporaryToken(): Result { + saveRequestCount += 1 + return saveResult + } + + override suspend fun clearToken(): Result { + return Result.success(Unit) + } + } } diff --git a/build.gradle.kts b/build.gradle.kts index b546c74..40c0434 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.ksp) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f4a166..3eae6da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,12 +7,18 @@ espressoCore = "3.5.1" lifecycleRuntimeKtx = "2.6.1" navigationCompose = "2.9.7" activityCompose = "1.8.0" +dataStore = "1.2.1" kotlin = "2.2.10" +kotlinxSerialization = "1.9.0" composeBom = "2026.02.01" credentials = "1.6.0" googleId = "1.2.0" kakao = "2.23.4" coroutines = "1.10.2" +retrofit = "3.0.0" +hilt = "2.59.2" +androidxHilt = "1.3.0" +ksp = "2.3.9" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +30,7 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } @@ -37,7 +44,16 @@ androidx-credentials-play-services-auth = { group = "androidx.credentials", name googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleId" } kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }