From c03a58206c9be1477c0cbef24f91961632fbb8d7 Mon Sep 17 00:00:00 2001 From: cnsvkf Date: Sat, 30 May 2026 21:43:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/NotificationUiStateMapper.kt | 15 ++ .../component/NotificationCard.kt | 97 ++++++++++ .../component/NotificationFilterDropdown.kt | 95 ++++++++++ .../component/NotificationHeaderSection.kt | 75 ++++++++ .../notification/route/NotificationRoute.kt | 37 ++++ .../notification/screen/NotificationScreen.kt | 166 ++++++++++++++++++ .../notification/state/NotificationUiState.kt | 29 +++ .../viewmodel/NotificationViewModel.kt | 84 +++++++++ .../viewmodel/NotificationViewModelTest.kt | 102 +++++++++++ 9 files changed, 700 insertions(+) create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/NotificationUiStateMapper.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationCard.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationFilterDropdown.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationHeaderSection.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/route/NotificationRoute.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/screen/NotificationScreen.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/state/NotificationUiState.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModel.kt create mode 100644 app/src/test/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModelTest.kt diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/NotificationUiStateMapper.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/NotificationUiStateMapper.kt new file mode 100644 index 0000000..4ecade4 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/NotificationUiStateMapper.kt @@ -0,0 +1,15 @@ +package com.example.it_da.ui.screen.notification + +import com.example.it_da.domain.model.Notification +import com.example.it_da.ui.screen.notification.state.NotificationUiModel + +// Converts shared domain notification values into notification screen UI values. +fun Notification.toNotificationUiModel(): NotificationUiModel { + return NotificationUiModel( + id = id, + title = title, + description = message, + elapsedTime = elapsedTime, + isRead = isRead + ) +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationCard.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationCard.kt new file mode 100644 index 0000000..696d2a1 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationCard.kt @@ -0,0 +1,97 @@ +package com.example.it_da.ui.screen.notification.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaCard +import com.example.it_da.ui.screen.notification.state.NotificationUiModel +import com.example.it_da.ui.theme.ItdaPrimaryTextColor +import com.example.it_da.ui.theme.ItdaSecondaryTextColor + +private val NotificationTitleDescriptionSpacing = 8.dp +private val NotificationDescriptionMetadataSpacing = 22.dp +private const val NotificationMetadataFlexibleSpacingWeight = 1f + +// Displays one notification with its read state image and elapsed time. +@Composable +fun NotificationCard( + notification: NotificationUiModel, + onClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + ItdaCard( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 112.dp) + .clickable { + onClick(notification.id) + } + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 15.dp) + ) { + Text( + text = notification.title, + color = ItdaPrimaryTextColor, + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(NotificationTitleDescriptionSpacing)) + + Text( + text = notification.description, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(modifier = Modifier.height(NotificationDescriptionMetadataSpacing)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource( + id = if (notification.isRead) { + R.drawable.check + } else { + R.drawable.not_check + } + ), + contentDescription = stringResource( + id = if (notification.isRead) { + R.string.notification_read_description + } else { + R.string.notification_unread_description + } + ), + modifier = Modifier.size(14.dp) + ) + + Spacer(modifier = Modifier.weight(NotificationMetadataFlexibleSpacingWeight)) + + Text( + text = notification.elapsedTime, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationFilterDropdown.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationFilterDropdown.kt new file mode 100644 index 0000000..fc9eb18 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationFilterDropdown.kt @@ -0,0 +1,95 @@ +package com.example.it_da.ui.screen.notification.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.it_da.R +import com.example.it_da.ui.screen.notification.state.NotificationFilter +import com.example.it_da.ui.theme.ItdaInputBorderGray +import com.example.it_da.ui.theme.ItdaSecondaryTextColor + +// Displays the notification filter label, selected category, and selectable dropdown options. +@Composable +fun NotificationFilterDropdown( + selectedFilter: NotificationFilter, + isExpanded: Boolean, + onClick: () -> Unit, + onDismiss: () -> Unit, + onFilterSelected: (NotificationFilter) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.notification_filter_label), + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.titleMedium + ) + + Row( + modifier = Modifier + .padding(top = 14.dp) + .fillMaxWidth() + .height(48.dp) + .border( + width = 1.dp, + color = ItdaInputBorderGray, + shape = RoundedCornerShape(10.dp) + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = selectedFilter.labelResId), + modifier = Modifier.weight(1f), + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.titleMedium + ) + + Icon( + painter = painterResource(id = R.drawable.ic_down_arrow), + contentDescription = stringResource( + id = R.string.notification_filter_open_description + ), + tint = ItdaSecondaryTextColor, + modifier = Modifier.size(24.dp) + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = onDismiss + ) { + NotificationFilter.entries.forEach { filter -> + DropdownMenuItem( + text = { + Text( + text = stringResource(id = filter.labelResId), + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + onFilterSelected(filter) + } + ) + } + } + } +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationHeaderSection.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationHeaderSection.kt new file mode 100644 index 0000000..a56d893 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/component/NotificationHeaderSection.kt @@ -0,0 +1,75 @@ +package com.example.it_da.ui.screen.notification.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.stringResource +import androidx.compose.ui.unit.dp +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaImageButton +import com.example.it_da.ui.theme.ItdaButtonTextColor +import com.example.it_da.ui.theme.ItdaHomeExploreButtonGray +import com.example.it_da.ui.theme.ItdaPrimaryTextColor + +private const val NotificationHeaderFlexibleSpacingWeight = 1f + +// Displays the notification screen back action. +@Composable +fun NotificationBackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + ItdaImageButton( + imageResId = R.drawable.backbutton, + contentDescription = stringResource(id = R.string.notification_back_description), + onClick = onClick, + modifier = modifier + .size(35.dp), + imageModifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(21.dp) + ) +} + +// Displays the notification title and the action that marks every item as read. +@Composable +fun NotificationHeaderSection( + onReadAllClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.notification_screen_title), + color = ItdaPrimaryTextColor, + style = MaterialTheme.typography.displayLarge + ) + + Spacer(modifier = Modifier.weight(NotificationHeaderFlexibleSpacingWeight)) + + Surface( + modifier = Modifier.clickable(onClick = onReadAllClick), + shape = RoundedCornerShape(10.dp), + color = ItdaHomeExploreButtonGray + ) { + Text( + text = stringResource(id = R.string.notification_read_all), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), + color = ItdaButtonTextColor, + style = MaterialTheme.typography.bodySmall + ) + } + } +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/route/NotificationRoute.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/route/NotificationRoute.kt new file mode 100644 index 0000000..538c5b5 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/route/NotificationRoute.kt @@ -0,0 +1,37 @@ +package com.example.it_da.ui.screen.notification.route + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.example.it_da.ui.screen.notification.screen.NotificationScreen +import com.example.it_da.ui.screen.notification.viewmodel.NotificationViewModel + +// Connects notification screen events and StateFlow state to the notification UI. +@Composable +fun NotificationRoute( + onBackClick: () -> Unit, + onHomeTabClick: () -> Unit, + onExploreTabClick: () -> Unit, + onCreateProjectClick: () -> Unit, + onNotificationTabClick: () -> Unit, + onProfileTabClick: () -> Unit, + viewModel: NotificationViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + NotificationScreen( + uiState = uiState, + onBackClick = onBackClick, + onReadAllClick = viewModel::onReadAllClick, + onFilterMenuClick = viewModel::onFilterMenuClick, + onFilterMenuDismiss = viewModel::onFilterMenuDismiss, + onFilterSelected = viewModel::onFilterSelected, + onNotificationClick = viewModel::onNotificationClick, + onHomeTabClick = onHomeTabClick, + onExploreTabClick = onExploreTabClick, + onCreateProjectClick = onCreateProjectClick, + onNotificationTabClick = onNotificationTabClick, + onProfileTabClick = onProfileTabClick + ) +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/screen/NotificationScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/screen/NotificationScreen.kt new file mode 100644 index 0000000..25b0f9b --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/screen/NotificationScreen.kt @@ -0,0 +1,166 @@ +package com.example.it_da.ui.screen.notification.screen + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.it_da.ui.commonComponent.ItdaBottomNavigationBar +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.screen.notification.component.NotificationBackButton +import com.example.it_da.ui.screen.notification.component.NotificationCard +import com.example.it_da.ui.screen.notification.component.NotificationFilterDropdown +import com.example.it_da.ui.screen.notification.component.NotificationHeaderSection +import com.example.it_da.ui.screen.notification.state.NotificationFilter +import com.example.it_da.ui.screen.notification.state.NotificationUiModel +import com.example.it_da.ui.screen.notification.state.NotificationUiState +import com.example.it_da.ui.theme.ITDATheme + +private val NotificationCardSpacing = 12.dp + +// Assembles the notification screen from state-driven sections and shared navigation. +@Composable +fun NotificationScreen( + uiState: NotificationUiState, + onBackClick: () -> Unit, + onReadAllClick: () -> Unit, + onFilterMenuClick: () -> Unit, + onFilterMenuDismiss: () -> Unit, + onFilterSelected: (NotificationFilter) -> Unit, + onNotificationClick: (String) -> Unit, + onHomeTabClick: () -> Unit, + onExploreTabClick: () -> Unit, + onCreateProjectClick: () -> Unit, + onNotificationTabClick: () -> Unit, + onProfileTabClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + ) { + NotificationContent( + uiState = uiState, + onBackClick = onBackClick, + onReadAllClick = onReadAllClick, + onFilterMenuClick = onFilterMenuClick, + onFilterMenuDismiss = onFilterMenuDismiss, + onFilterSelected = onFilterSelected, + onNotificationClick = onNotificationClick + ) + + ItdaBottomNavigationBar( + onHomeClick = onHomeTabClick, + onExploreClick = onExploreTabClick, + onCreateProjectClick = onCreateProjectClick, + onNotificationClick = onNotificationTabClick, + onProfileClick = onProfileTabClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + ) + } +} + +// Lays out scrollable notification controls and cards above the fixed bottom navigation. +@Composable +private fun NotificationContent( + uiState: NotificationUiState, + onBackClick: () -> Unit, + onReadAllClick: () -> Unit, + onFilterMenuClick: () -> Unit, + onFilterMenuDismiss: () -> Unit, + onFilterSelected: (NotificationFilter) -> Unit, + onNotificationClick: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 30.dp) + .padding(top = 18.dp) + .padding(bottom = ItdaLayoutDefaults.BottomNavigationContentPadding) + ) { + NotificationBackButton(onClick = onBackClick) + + NotificationHeaderSection( + onReadAllClick = onReadAllClick, + modifier = Modifier.padding(top = 26.dp) + ) + + NotificationFilterDropdown( + selectedFilter = uiState.selectedFilter, + isExpanded = uiState.isFilterMenuExpanded, + onClick = onFilterMenuClick, + onDismiss = onFilterMenuDismiss, + onFilterSelected = onFilterSelected, + modifier = Modifier.padding(top = 23.dp) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 22.dp), + verticalArrangement = Arrangement.spacedBy(NotificationCardSpacing) + ) { + uiState.notifications.forEach { notification -> + NotificationCard( + notification = notification, + onClick = onNotificationClick + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NotificationScreenPreview() { + ITDATheme { + NotificationScreen( + uiState = NotificationUiState( + notifications = listOf( + NotificationUiModel( + id = "preview-unread", + title = "새 프로젝트 추천", + description = "나의 기술 스택과 일치하는 프로그램이 등록되었습니다.", + elapsedTime = "5분 전", + isRead = false + ), + NotificationUiModel( + id = "preview-read", + title = "팀 멤버 합류", + description = "백엔드 개발자 1명이 팀에 합류했습니다.", + elapsedTime = "1시간 전", + isRead = true + ) + ) + ), + onBackClick = {}, + onReadAllClick = {}, + onFilterMenuClick = {}, + onFilterMenuDismiss = {}, + onFilterSelected = {}, + onNotificationClick = {}, + onHomeTabClick = {}, + onExploreTabClick = {}, + onCreateProjectClick = {}, + onNotificationTabClick = {}, + onProfileTabClick = {} + ) + } +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/state/NotificationUiState.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/state/NotificationUiState.kt new file mode 100644 index 0000000..c66f96a --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/state/NotificationUiState.kt @@ -0,0 +1,29 @@ +package com.example.it_da.ui.screen.notification.state + +import androidx.annotation.StringRes +import com.example.it_da.R + +// Represents the selectable notification categories shown in the filter dropdown. +enum class NotificationFilter( + @StringRes val labelResId: Int +) { + ALL(R.string.notification_filter_all), + UNREAD(R.string.notification_filter_unread), + READ(R.string.notification_filter_read) +} + +// Represents one notification item displayed on the notification screen. +data class NotificationUiModel( + val id: String, + val title: String, + val description: String, + val elapsedTime: String, + val isRead: Boolean +) + +// Represents all state needed to render the notification screen. +data class NotificationUiState( + val notifications: List = emptyList(), + val selectedFilter: NotificationFilter = NotificationFilter.ALL, + val isFilterMenuExpanded: Boolean = false +) diff --git a/app/src/main/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModel.kt b/app/src/main/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModel.kt new file mode 100644 index 0000000..58654a6 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModel.kt @@ -0,0 +1,84 @@ +package com.example.it_da.ui.screen.notification.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.it_da.data.repository.NotificationRepository +import com.example.it_da.domain.model.Notification +import com.example.it_da.ui.screen.notification.state.NotificationFilter +import com.example.it_da.ui.screen.notification.state.NotificationUiModel +import com.example.it_da.ui.screen.notification.state.NotificationUiState +import com.example.it_da.ui.screen.notification.toNotificationUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class NotificationViewModel @Inject constructor( + private val notificationRepository: NotificationRepository +) : ViewModel() { + private val selectedFilter = MutableStateFlow(NotificationFilter.ALL) + private val isFilterMenuExpanded = MutableStateFlow(false) + + val uiState = combine( + notificationRepository.notifications, + selectedFilter, + isFilterMenuExpanded + ) { notifications, filter, isExpanded -> + NotificationUiState( + notifications = notifications + .filterBy(filter) + .map(Notification::toNotificationUiModel), + selectedFilter = filter, + isFilterMenuExpanded = isExpanded + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = NotificationUiState() + ) + + // Opens the filter menu so the user can choose which notification items are visible. + fun onFilterMenuClick() { + isFilterMenuExpanded.value = true + } + + // Closes the filter menu without changing the currently selected category. + fun onFilterMenuDismiss() { + isFilterMenuExpanded.value = false + } + + // Applies the selected category and publishes only matching shared notification items. + fun onFilterSelected(filter: NotificationFilter) { + selectedFilter.value = filter + isFilterMenuExpanded.value = false + } + + // Marks every shared notification as read through the repository. + fun onReadAllClick() { + viewModelScope.launch { + notificationRepository.markAllAsRead() + } + } + + // Marks a selected shared notification as read through the repository. + fun onNotificationClick(notificationId: String) { + viewModelScope.launch { + notificationRepository.markAsRead(notificationId) + } + } +} + +// Filters domain notifications without leaking filtering logic into Compose UI. +private fun List.filterBy( + filter: NotificationFilter +): List { + return when (filter) { + NotificationFilter.ALL -> this + NotificationFilter.UNREAD -> filterNot(Notification::isRead) + NotificationFilter.READ -> filter(Notification::isRead) + } +} diff --git a/app/src/test/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModelTest.kt b/app/src/test/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModelTest.kt new file mode 100644 index 0000000..ef1f9fd --- /dev/null +++ b/app/src/test/java/com/example/it_da/ui/screen/notification/viewmodel/NotificationViewModelTest.kt @@ -0,0 +1,102 @@ +package com.example.it_da.ui.screen.notification.viewmodel + +import com.example.it_da.data.repository.FakeNotificationRepository +import com.example.it_da.data.store.DefaultNotificationStore +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import com.example.it_da.testing.MainDispatcherRule +import com.example.it_da.ui.screen.notification.state.NotificationFilter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NotificationViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun onReadAllClickMarksEverySharedNotificationAsRead() = runTest( + mainDispatcherRule.testDispatcher + ) { + val store = createNotificationStore() + val viewModel = NotificationViewModel(FakeNotificationRepository(store)) + runCurrent() + + viewModel.onReadAllClick() + advanceUntilIdle() + + assertTrue(store.notifications.value.all(Notification::isRead)) + assertTrue(viewModel.uiState.value.notifications.all { notification -> + notification.isRead + }) + } + + @Test + fun onNotificationClickRemovesReadNotificationFromUnreadFilter() = runTest( + mainDispatcherRule.testDispatcher + ) { + val viewModel = NotificationViewModel(FakeNotificationRepository(createNotificationStore())) + runCurrent() + viewModel.onFilterSelected(NotificationFilter.UNREAD) + runCurrent() + val notificationId = viewModel.uiState.value.notifications.first().id + + viewModel.onNotificationClick(notificationId) + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.notifications.size) + assertFalse(viewModel.uiState.value.notifications.any { notification -> + notification.id == notificationId + }) + } + + @Test + fun onFilterSelectedClosesMenuAndPublishesMatchingNotifications() = runTest( + mainDispatcherRule.testDispatcher + ) { + val viewModel = NotificationViewModel(FakeNotificationRepository(createNotificationStore())) + runCurrent() + viewModel.onFilterMenuClick() + + viewModel.onFilterSelected(NotificationFilter.READ) + runCurrent() + + assertFalse(viewModel.uiState.value.isFilterMenuExpanded) + assertTrue(viewModel.uiState.value.notifications.all { notification -> + notification.isRead + }) + } + + private fun createNotificationStore(): DefaultNotificationStore { + return DefaultNotificationStore().apply { + replaceNotifications( + listOf( + createNotification(id = "unread-1", isRead = false), + createNotification(id = "unread-2", isRead = false), + createNotification(id = "read", isRead = true) + ) + ) + } + } + + private fun createNotification( + id: String, + isRead: Boolean + ): Notification { + return Notification( + id = id, + type = NotificationType.MESSAGE, + title = "알림 제목", + message = "알림 내용", + elapsedTime = "방금 전", + isRead = isRead + ) + } +} From 39c20e9e411908efbc5587a0f8e225dc2d61e0b5 Mon Sep 17 00:00:00 2001 From: cnsvkf Date: Sat, 30 May 2026 21:43:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/it_da/navigation/AppNavigation.kt | 24 +++++++++++++++++-- .../com/example/it_da/navigation/AppRoute.kt | 3 +++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/it_da/navigation/AppNavigation.kt b/app/src/main/java/com/example/it_da/navigation/AppNavigation.kt index f9b3266..750b2eb 100644 --- a/app/src/main/java/com/example/it_da/navigation/AppNavigation.kt +++ b/app/src/main/java/com/example/it_da/navigation/AppNavigation.kt @@ -7,6 +7,7 @@ import androidx.navigation.compose.rememberNavController import com.example.it_da.ui.screen.home.HomeRoute import com.example.it_da.ui.screen.login.LoginLaunchRoute import com.example.it_da.ui.screen.login.LoginRoute +import com.example.it_da.ui.screen.notification.route.NotificationRoute import com.example.it_da.ui.screen.projectcreate.route.ProjectCreateRoute import com.example.it_da.ui.screen.signup.route.SignUpAccountRoute import com.example.it_da.ui.screen.signup.route.SignUpAdditionalInfoRoute @@ -44,6 +45,11 @@ fun AppNavigation() { launchSingleTop = true } } + val navigateNotification: () -> Unit = { + navController.navigate(NotificationDestination) { + launchSingleTop = true + } + } NavHost( navController = navController, startDestination = LoginLaunchDestination @@ -89,7 +95,8 @@ fun AppNavigation() { composable { HomeRoute( - onCreateProjectClick = navigateProjectCreate + onCreateProjectClick = navigateProjectCreate, + onNotificationClick = navigateNotification ) } @@ -102,7 +109,20 @@ fun AppNavigation() { onHomeTabClick = navigateHome, onExploreTabClick = {}, onCreateProjectClick = navigateProjectCreate, - onNotificationTabClick = {}, + onNotificationTabClick = navigateNotification, + onProfileTabClick = {} + ) + } + + composable { + NotificationRoute( + onBackClick = { + navController.popBackStack() + }, + onHomeTabClick = navigateHome, + onExploreTabClick = {}, + onCreateProjectClick = navigateProjectCreate, + onNotificationTabClick = navigateNotification, onProfileTabClick = {} ) } diff --git a/app/src/main/java/com/example/it_da/navigation/AppRoute.kt b/app/src/main/java/com/example/it_da/navigation/AppRoute.kt index 9b9a803..a102803 100644 --- a/app/src/main/java/com/example/it_da/navigation/AppRoute.kt +++ b/app/src/main/java/com/example/it_da/navigation/AppRoute.kt @@ -20,3 +20,6 @@ data object HomeDestination @Serializable data object ProjectCreateDestination + +@Serializable +data object NotificationDestination