From f9090283949efe231807e2b34d545063d71b51ac Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:26:12 +0900 Subject: [PATCH 01/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20Response=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/NotificationListResponse.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationListResponse.kt diff --git a/app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationListResponse.kt b/app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationListResponse.kt new file mode 100644 index 00000000..b4133a98 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationListResponse.kt @@ -0,0 +1,21 @@ +package com.texthip.thip.data.model.notification.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationListResponse( + @SerialName("notifications") val notifications: List, + @SerialName("nextCursor") val nextCursor: String?, + @SerialName("isLast") val isLast: Boolean +) + +@Serializable +data class NotificationResponse( + @SerialName("notificationId") val notificationId: Int, + @SerialName("title") val title: String, + @SerialName("content") val content: String, + @SerialName("isChecked") val isChecked: Boolean, + @SerialName("notificationType") val notificationType: String, + @SerialName("postDate") val postDate: String +) \ No newline at end of file From 3fda29302c58b4facdd307dceb4b0d709049b989 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:26:30 +0900 Subject: [PATCH 02/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20Type=20enum=20class=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/common/alarmpage/mock/NotificationType.kt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/ui/common/alarmpage/mock/NotificationType.kt diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/mock/NotificationType.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/mock/NotificationType.kt new file mode 100644 index 00000000..1a1f755e --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/mock/NotificationType.kt @@ -0,0 +1,7 @@ +package com.texthip.thip.ui.common.alarmpage.mock + +enum class NotificationType(val value: String) { + FEED_AND_ROOM("feedAndRoom"), + FEED("feed"), + ROOM("room") +} \ No newline at end of file From 9d1e3bc520ea0ab052828ca0417a7bbe2bcf6aed Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:27:40 +0900 Subject: [PATCH 03/29] =?UTF-8?q?[ui]:=20=EC=95=8C=EB=A6=BC=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20row=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/common/alarmpage/component/AlarmFilterRow.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/component/AlarmFilterRow.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/component/AlarmFilterRow.kt index f70659bc..542b6db7 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/component/AlarmFilterRow.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/component/AlarmFilterRow.kt @@ -24,11 +24,13 @@ fun AlarmFilterRow( OptionChipButton( text = stringResource(R.string.alarm_feed), isFilled = true, + isSelected = selectedStates[0], onClick = { onToggle(0) } ) OptionChipButton( text = stringResource(R.string.alarm_group), isFilled = true, + isSelected = selectedStates[1], onClick = { onToggle(1) } ) } From 18079dbe679777c45dcab041254ff9085508ebb8 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:29:38 +0900 Subject: [PATCH 04/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=EC=9A=A9=20UiState=20=EA=B5=AC=ED=98=84=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/common/alarmpage/viewmodel/AlarmUiState.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt new file mode 100644 index 00000000..4c8fb8ba --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt @@ -0,0 +1,15 @@ +package com.texthip.thip.ui.common.alarmpage.viewmodel + +import com.texthip.thip.data.model.notification.response.NotificationResponse +import com.texthip.thip.ui.common.alarmpage.mock.NotificationType + +data class AlarmUiState( + val notifications: List = emptyList(), + val currentNotificationType: NotificationType = NotificationType.FEED_AND_ROOM, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = true, + val error: String? = null +) { + val canLoadMore: Boolean get() = !isLoading && !isLoadingMore && hasMore +} \ No newline at end of file From d140dfbb1fdd76293518bedd4ce4b9038139d027 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:30:18 +0900 Subject: [PATCH 05/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20API=20Service,=20Repository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/data/repository/NotificationRepository.kt | 11 +++++++++++ .../texthip/thip/data/service/NotificationService.kt | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt index bdb8b381..09c1dea2 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt @@ -6,6 +6,7 @@ import com.texthip.thip.data.model.notification.request.FcmTokenRequest import com.texthip.thip.data.model.notification.request.FcmTokenDeleteRequest import com.texthip.thip.data.model.notification.request.NotificationEnabledRequest import com.texthip.thip.data.model.notification.response.NotificationEnabledResponse +import com.texthip.thip.data.model.notification.response.NotificationListResponse import com.texthip.thip.data.service.NotificationService import com.texthip.thip.utils.auth.getAppScopeDeviceId import dagger.hilt.android.qualifiers.ApplicationContext @@ -60,4 +61,14 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } + + suspend fun getNotifications( + type: String? = null, + cursor: String? = null + ): Result { + return runCatching { + val response = notificationService.getNotifications(cursor, type) + response.handleBaseResponse().getOrNull() + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt b/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt index 3447316d..f5d06773 100644 --- a/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt @@ -4,6 +4,7 @@ import com.texthip.thip.data.model.notification.request.FcmTokenRequest import com.texthip.thip.data.model.notification.request.FcmTokenDeleteRequest import com.texthip.thip.data.model.notification.request.NotificationEnabledRequest import com.texthip.thip.data.model.notification.response.NotificationEnabledResponse +import com.texthip.thip.data.model.notification.response.NotificationListResponse import com.texthip.thip.data.model.base.BaseResponse import retrofit2.http.Body import retrofit2.http.DELETE @@ -32,4 +33,10 @@ interface NotificationService { suspend fun deleteFcmToken( @Body request: FcmTokenDeleteRequest ): BaseResponse + + @GET("notifications") + suspend fun getNotifications( + @Query("cursor") cursor: String? = null, + @Query("type") type: String? = null + ): BaseResponse } \ No newline at end of file From bdbf1832e8c169f396da3b78695dccf925f00685 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:31:43 +0900 Subject: [PATCH 06/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20viewModel=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmpage/viewmodel/AlarmViewModel.kt | 107 +++++++++++++++--- 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt index efa44466..71a2239a 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt @@ -1,27 +1,106 @@ package com.texthip.thip.ui.common.alarmpage.viewmodel import androidx.lifecycle.ViewModel -import com.texthip.thip.ui.common.alarmpage.mock.AlarmItem +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.repository.NotificationRepository +import com.texthip.thip.ui.common.alarmpage.mock.NotificationType +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject -class AlarmViewModel : ViewModel() { - private val _alarmItems = MutableStateFlow>(emptyList()) - val alarmItems: StateFlow> = _alarmItems.asStateFlow() +@HiltViewModel +class AlarmViewModel @Inject constructor( + private val repository: NotificationRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AlarmUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var nextCursor: String? = null + private var isLastPage = false + private var isLoadingData = false + + private fun updateState(update: (AlarmUiState) -> AlarmUiState) { + _uiState.value = update(_uiState.value) + } - // 알림 더미 데이터 init { - _alarmItems.value = listOf( - AlarmItem(1, "피드", "내 글을 좋아합니다.", "user123님이 내 글에 좋아요를 눌렀어요.", "2시간 전", false), - AlarmItem(2, "모임", "같이 읽기를 시작했어요!", "모임방에서 20분 동안 같이 읽기가 시작되었어요!", "7시간 전", false), - AlarmItem(4, "모임", "투표가 시작되었어요!", "투표지를 먼저 열람합니다.", "17시간 전", false), - AlarmItem(5, "피드", "팔로워가 새 글을 올렸어요.", "user456님이 새 리뷰를 작성했습니다.", "1일 전", true), - AlarmItem(6, "모임", "새로운 모임방 초대", "호르몬 체인지 완독하는 방에 초대되었습니다.", "2일 전", false) - ) + loadNotifications(reset = true) + } + + fun loadNotifications(reset: Boolean = false) { + if (isLoadingData && !reset) return + if (isLastPage && !reset) return + + viewModelScope.launch { + try { + isLoadingData = true + + if (reset) { + updateState { + it.copy( + isLoading = true, + notifications = emptyList(), + hasMore = true + ) + } + nextCursor = null + isLastPage = false + } else { + updateState { it.copy(isLoadingMore = true) } + } + + val type = + if (uiState.value.currentNotificationType == NotificationType.FEED_AND_ROOM) { + null + } else { + uiState.value.currentNotificationType.value + } + + repository.getNotifications(type, nextCursor) + .onSuccess { notificationListResponse -> + notificationListResponse?.let { response -> + val currentList = + if (reset) emptyList() else uiState.value.notifications + updateState { + it.copy( + notifications = currentList + response.notifications, + error = null, + hasMore = !response.isLast + ) + } + nextCursor = response.nextCursor + isLastPage = response.isLast + } ?: run { + updateState { it.copy(hasMore = false) } + isLastPage = true + } + } + .onFailure { exception -> + updateState { it.copy(error = exception.message) } + } + } finally { + isLoadingData = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } + } + } + } + + fun loadMoreNotifications() { + loadNotifications(reset = false) + } + + fun refreshData() { + loadNotifications(reset = true) } - fun onCardClick(item: AlarmItem) { - // TODO: 알림 카드 클릭 처리 + fun changeNotificationType(notificationType: NotificationType) { + if (notificationType != uiState.value.currentNotificationType) { + updateState { it.copy(currentNotificationType = notificationType) } + loadNotifications(reset = true) + } } } \ No newline at end of file From 32376cdf6441786da5e816b328341e862fd86c09 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:32:04 +0900 Subject: [PATCH 07/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20Screen=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/common/alarmpage/screen/AlarmScreen.kt | 233 +++++++++++++----- 1 file changed, 172 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt index 3044e2e5..3b0dd18a 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt @@ -9,39 +9,88 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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 androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R +import com.texthip.thip.data.model.notification.response.NotificationResponse import com.texthip.thip.ui.common.alarmpage.component.AlarmFilterRow -import com.texthip.thip.ui.common.alarmpage.mock.AlarmItem import com.texthip.thip.ui.common.alarmpage.component.CardAlarm +import com.texthip.thip.ui.common.alarmpage.mock.NotificationType +import com.texthip.thip.ui.common.alarmpage.viewmodel.AlarmUiState +import com.texthip.thip.ui.common.alarmpage.viewmodel.AlarmViewModel import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AlarmScreen( - alarmItems: List, - onCardClick: (AlarmItem) -> Unit = {}, // 나중에 서버랑 연동할 때 사용 - onNavigateBack: () -> Unit = {} + onNavigateBack: () -> Unit = {}, + viewModel: AlarmViewModel = hiltViewModel() ) { - var selectedStates by remember { mutableStateOf(booleanArrayOf(false, false)) } - var alarms by remember { mutableStateOf(alarmItems) } + val uiState by viewModel.uiState.collectAsState() - val filteredList = when { - selectedStates[0] && !selectedStates[1] -> alarms.filter { it.badgeText == stringResource(R.string.alarm_feed) } - !selectedStates[0] && selectedStates[1] -> alarms.filter { it.badgeText == stringResource(R.string.alarm_group) } - else -> alarms + LaunchedEffect(key1 = Unit) { + viewModel.refreshData() + } + + AlarmContent( + uiState = uiState, + onNavigateBack = onNavigateBack, + onRefresh = { viewModel.refreshData() }, + onLoadMore = { viewModel.loadMoreNotifications() }, + onChangeNotificationType = { viewModel.changeNotificationType(it) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlarmContent( + uiState: AlarmUiState, + onNavigateBack: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onLoadMore: () -> Unit = {}, + onChangeNotificationType: (NotificationType) -> Unit = {} +) { + val listState = rememberLazyListState() + + // 무한 스크롤 로직 + val shouldLoadMore by remember(uiState.canLoadMore, uiState.isLoadingMore) { + derivedStateOf { + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + uiState.canLoadMore && !uiState.isLoadingMore && totalItems > 0 && lastVisibleIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + + // 필터 상태 매핑 + val selectedStates = remember(uiState.currentNotificationType) { + when (uiState.currentNotificationType) { + NotificationType.FEED -> booleanArrayOf(true, false) + NotificationType.ROOM -> booleanArrayOf(false, true) + else -> booleanArrayOf(false, false) // FEED_AND_ROOM + } } Column( @@ -52,49 +101,79 @@ fun AlarmScreen( title = stringResource(R.string.alarm_string), onLeftClick = onNavigateBack, ) - Column( - Modifier - .fillMaxSize() - .padding(horizontal = 20.dp) + + PullToRefreshBox( + isRefreshing = uiState.isLoading, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() ) { - Spacer(modifier = Modifier.height(20.dp)) - AlarmFilterRow( - selectedStates = selectedStates, onToggle = { idx -> - selectedStates = selectedStates.copyOf().also { it[idx] = !it[idx] } - }) - Spacer(modifier = Modifier.height(20.dp)) - - if (filteredList.isEmpty()) { - - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.alarm_notification_comment), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White - ) - } - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues(bottom = 20.dp), - modifier = Modifier.fillMaxSize() - ) { - items(filteredList, key = { it.id }) { alarm -> - CardAlarm( - badgeText = alarm.badgeText, - title = alarm.title, - message = alarm.message, - timeAgo = alarm.timeAgo, - isRead = alarm.isRead, - onClick = { - alarms = alarms.map { - if (it.id == alarm.id) it.copy(isRead = true) else it + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + AlarmFilterRow( + selectedStates = selectedStates, + onToggle = { idx -> + val newNotificationType = when { + // 피드 버튼을 눌렀을 때 + idx == 0 -> { + if (selectedStates[0]) { + // 이미 선택된 상태면 전체로 변경 + NotificationType.FEED_AND_ROOM + } else { + // 선택되지 않은 상태면 피드만 + NotificationType.FEED + } + } + // 모임 버튼을 눌렀을 때 + idx == 1 -> { + if (selectedStates[1]) { + NotificationType.FEED_AND_ROOM + } else { + NotificationType.ROOM } - }) + } + + else -> NotificationType.FEED_AND_ROOM + } + onChangeNotificationType(newNotificationType) + } + ) + Spacer(modifier = Modifier.height(20.dp)) + + if (uiState.notifications.isNotEmpty()) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 20.dp), + modifier = Modifier.fillMaxSize() + ) { + items(uiState.notifications, key = { it.notificationId }) { notification -> + CardAlarm( + badgeText = notification.notificationType, + title = removeBracketPrefix(notification.title), + message = notification.content, + timeAgo = notification.postDate, + isRead = notification.isChecked, + onClick = { + // TODO: 알림 읽음 처리 + } + ) + } + } + } else if (!uiState.isLoading) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.alarm_notification_comment), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) } } } @@ -102,17 +181,46 @@ fun AlarmScreen( } } +private fun removeBracketPrefix(title: String): String { + return title.replace(Regex("^\\[.*?\\]\\s*"), "").trim() +} + @Preview(showBackground = true) @Composable fun AlarmScreenPreview() { ThipTheme { - AlarmScreen( - alarmItems = listOf( - AlarmItem(1, "피드", "내 글을 좋아합니다.", "user123님이 내 글에 좋아요를 눌렀어요.", "2", false), - AlarmItem(2, "모임", "같이 읽기를 시작했어요!", "모임방에서 20분 동안 같이 읽기가 시작되었어요!", "7", false), - AlarmItem(3, "피드", "내 글에 댓글이 달렸어요.", "user1: 진짜 공감합니다!", "2025.01.12", true), - AlarmItem(4, "모임", "투표가 시작되었어요!", "투표지를 먼저 열람합니다.", "17", false) + AlarmContent( + uiState = AlarmUiState( + notifications = listOf( + NotificationResponse( + notificationId = 1, + title = "[피드] 내 글을 좋아합니다.", + content = "user123님이 내 글에 좋아요를 눌렀어요.", + isChecked = false, + notificationType = "피드", + postDate = "2시간 전" + ), + NotificationResponse( + notificationId = 2, + title = "[모임] 같이 읽기를 시작했어요!", + content = "모임방에서 20분 동안 같이 읽기가 시작되었어요!", + isChecked = false, + notificationType = "모임", + postDate = "7시간 전" + ), + NotificationResponse( + notificationId = 3, + title = "[모임] 투표가 시작되었어요!", + content = "투표지를 먼저 열람합니다.", + isChecked = true, + notificationType = "모임", + postDate = "17시간 전" + ) + ), + currentNotificationType = NotificationType.FEED_AND_ROOM, + isLoading = false, + hasMore = true ) ) } @@ -122,8 +230,11 @@ fun AlarmScreenPreview() { @Composable fun AlarmScreenEmptyPreview() { ThipTheme { - AlarmScreen( - alarmItems = emptyList() + AlarmContent( + uiState = AlarmUiState( + notifications = emptyList(), + isLoading = false + ) ) } } \ No newline at end of file From 0c7a691b5009862758024e29ff421d73917b0f61 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:32:23 +0900 Subject: [PATCH 08/29] =?UTF-8?q?[feat]:=20Common=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/navigator/navigations/CommonNavigation.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt index 734373c7..395eb3cc 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt @@ -1,13 +1,9 @@ package com.texthip.thip.ui.navigator.navigations -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import com.texthip.thip.ui.common.alarmpage.screen.AlarmScreen -import com.texthip.thip.ui.common.alarmpage.viewmodel.AlarmViewModel import com.texthip.thip.ui.common.screen.RegisterBookScreen import com.texthip.thip.ui.navigator.routes.CommonRoutes @@ -18,12 +14,7 @@ fun NavGraphBuilder.commonNavigation( ) { // Alarm 화면 composable { - val alarmViewModel: AlarmViewModel = viewModel() - val alarmItems by alarmViewModel.alarmItems.collectAsState() - AlarmScreen( - alarmItems = alarmItems, - onCardClick = { alarmViewModel.onCardClick(it) }, onNavigateBack = navigateBack ) } From c91ad6a884640f660040de77009357aa4ae1e439 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 23 Sep 2025 21:32:47 +0900 Subject: [PATCH 09/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=ED=83=91?= =?UTF-8?q?=EB=B0=94=20=EC=95=8C=EB=A6=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/texthip/thip/ui/feed/screen/FeedScreen.kt | 12 ++++++++---- .../com/texthip/thip/ui/group/screen/GroupScreen.kt | 9 +++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt index d55debb8..96f7b270 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt @@ -43,10 +43,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController import com.texthip.thip.R import com.texthip.thip.data.model.feed.response.AllFeedItem import com.texthip.thip.data.model.users.response.RecentWriterList +import com.texthip.thip.ui.common.alarmpage.viewmodel.AlarmViewModel import com.texthip.thip.ui.common.buttons.FloatingButton import com.texthip.thip.ui.common.header.AuthorHeader import com.texthip.thip.ui.common.header.HeaderMenuBarTab @@ -66,7 +66,6 @@ import com.texthip.thip.utils.color.hexToColor import kotlinx.coroutines.delay import kotlinx.coroutines.launch - @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedScreen( @@ -85,8 +84,10 @@ fun FeedScreen( onRefreshConsumed: () -> Unit = {}, navController: NavHostController, feedViewModel: FeedViewModel = hiltViewModel(), + alarmViewModel: AlarmViewModel = hiltViewModel() ) { val feedUiState by feedViewModel.uiState.collectAsState() + val alarmUiState by alarmViewModel.uiState.collectAsState() val scope = rememberCoroutineScope() var showProgressBar by remember { mutableStateOf(false) } val progress = remember { Animatable(0f) } @@ -256,6 +257,7 @@ fun FeedScreen( FeedContent( feedUiState = feedUiState, + hasUnreadNotifications = alarmUiState.notifications.any { !it.isChecked }, showProgressBar = showProgressBar, progress = progress.value, currentListState = currentListState, @@ -289,7 +291,8 @@ fun FeedScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FeedContent( - feedUiState: com.texthip.thip.ui.feed.viewmodel.FeedUiState, + feedUiState: FeedUiState, + hasUnreadNotifications: Boolean, showProgressBar: Boolean, progress: Float, currentListState: LazyListState, @@ -331,7 +334,7 @@ private fun FeedContent( ) { LogoTopAppBar( leftIcon = painterResource(R.drawable.ic_plusfriend), - hasNotification = false, + hasNotification = hasUnreadNotifications, onLeftClick = onNavigateToSearchPeople, onRightClick = onNavigateToNotification, ) @@ -659,6 +662,7 @@ private fun FeedContentPreview() { ) ) ), + hasUnreadNotifications = false, showProgressBar = false, progress = 0f, currentListState = LazyListState(), diff --git a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt index 307074b7..39184006 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt @@ -33,6 +33,7 @@ import com.texthip.thip.data.model.rooms.response.RoomMainList import com.texthip.thip.data.model.rooms.response.RoomMainResponse import com.texthip.thip.ui.common.buttons.FloatingButton import com.texthip.thip.ui.common.modal.ToastWithDate +import com.texthip.thip.ui.common.alarmpage.viewmodel.AlarmViewModel import com.texthip.thip.ui.common.topappbar.LogoTopAppBar import com.texthip.thip.ui.group.myroom.component.GroupMySectionHeader import com.texthip.thip.ui.group.myroom.component.GroupPager @@ -54,16 +55,19 @@ fun GroupScreen( onNavigateToGroupMy: () -> Unit = {}, // 내 모임방 화면으로 이동 onNavigateToGroupRecruit: (Int) -> Unit = {}, // 모집 중인 모임방 화면으로 이동 onNavigateToGroupRoom: (Int) -> Unit = {}, // 기록장 화면으로 이동 - viewModel: GroupViewModel = hiltViewModel() + viewModel: GroupViewModel = hiltViewModel(), + alarmViewModel: AlarmViewModel = hiltViewModel() ) { // 화면 재진입 시 데이터 새로고침 LaunchedEffect(Unit) { viewModel.resetToInitialState() } val uiState by viewModel.uiState.collectAsState() + val alarmUiState by alarmViewModel.uiState.collectAsState() GroupContent( uiState = uiState, + hasUnreadNotifications = alarmUiState.notifications.any { !it.isChecked }, onNavigateToMakeRoom = onNavigateToMakeRoom, onNavigateToGroupDone = onNavigateToGroupDone, onNavigateToAlarm = onNavigateToAlarm, @@ -82,6 +86,7 @@ fun GroupScreen( @Composable fun GroupContent( uiState: GroupUiState, + hasUnreadNotifications: Boolean = false, onNavigateToMakeRoom: () -> Unit = {}, onNavigateToGroupDone: () -> Unit = {}, onNavigateToAlarm: () -> Unit = {}, @@ -162,7 +167,7 @@ fun GroupContent( // 상단바 LogoTopAppBar( leftIcon = painterResource(R.drawable.ic_done), - hasNotification = false, + hasNotification = hasUnreadNotifications, onLeftClick = onNavigateToGroupDone, onRightClick = onNavigateToAlarm ) From 42f0b0181034146924d93e273ef3abf6bd637793 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 17:05:44 +0900 Subject: [PATCH 10/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20API=20Request,=20Response=20=EA=B5=AC=ED=98=84=20(#?= =?UTF-8?q?140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/NotificationCheckRequest.kt | 9 ++++++ .../response/NotificationCheckResponse.kt | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/data/model/notification/request/NotificationCheckRequest.kt create mode 100644 app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationCheckResponse.kt diff --git a/app/src/main/java/com/texthip/thip/data/model/notification/request/NotificationCheckRequest.kt b/app/src/main/java/com/texthip/thip/data/model/notification/request/NotificationCheckRequest.kt new file mode 100644 index 00000000..d22e02f0 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/notification/request/NotificationCheckRequest.kt @@ -0,0 +1,9 @@ +package com.texthip.thip.data.model.notification.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationCheckRequest( + @SerialName("notificationId") val notificationId: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationCheckResponse.kt b/app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationCheckResponse.kt new file mode 100644 index 00000000..fd6c546b --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/notification/response/NotificationCheckResponse.kt @@ -0,0 +1,29 @@ +package com.texthip.thip.data.model.notification.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class NotificationCheckResponse( + @SerialName("route") val route: NotificationRoute, + @SerialName("params") val params: Map +) + +@Serializable +enum class NotificationRoute { + @SerialName("FEED_USER") + FEED_USER, + + @SerialName("FEED_DETAIL") + FEED_DETAIL, + + @SerialName("ROOM_MAIN") + ROOM_MAIN, + + @SerialName("ROOM_DETAIL") + ROOM_DETAIL, + + @SerialName("ROOM_POST_DETAIL") + ROOM_POST_DETAIL +} \ No newline at end of file From 7580e37bfa86a0b20399d30ea4228bd05fd03fac Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 17:09:40 +0900 Subject: [PATCH 11/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20API=20Notification,=20Service=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/NotificationRepository.kt | 36 ++++++++++++++++--- .../thip/data/service/NotificationService.kt | 15 +++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt index 09c1dea2..3ee59344 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt @@ -5,11 +5,16 @@ import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.notification.request.FcmTokenRequest import com.texthip.thip.data.model.notification.request.FcmTokenDeleteRequest import com.texthip.thip.data.model.notification.request.NotificationEnabledRequest +import com.texthip.thip.data.model.notification.request.NotificationCheckRequest import com.texthip.thip.data.model.notification.response.NotificationEnabledResponse import com.texthip.thip.data.model.notification.response.NotificationListResponse +import com.texthip.thip.data.model.notification.response.NotificationCheckResponse import com.texthip.thip.data.service.NotificationService import com.texthip.thip.utils.auth.getAppScopeDeviceId import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +23,11 @@ class NotificationRepository @Inject constructor( private val notificationService: NotificationService, @param:ApplicationContext private val context: Context ) { + private val _notificationUpdateFlow = MutableSharedFlow() + val notificationUpdateFlow: SharedFlow = _notificationUpdateFlow.asSharedFlow() + + private val _notificationRefreshFlow = MutableSharedFlow() + val notificationRefreshFlow: SharedFlow = _notificationRefreshFlow.asSharedFlow() suspend fun registerFcmToken( deviceId: String, fcmToken: String @@ -32,7 +42,7 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } - + suspend fun getNotificationEnableState(): Result { return runCatching { val deviceId = context.getAppScopeDeviceId() @@ -40,7 +50,7 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } - + suspend fun updateNotificationEnabled(enabled: Boolean): Result { return runCatching { val deviceId = context.getAppScopeDeviceId() @@ -52,7 +62,7 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } - + suspend fun deleteFcmToken(): Result { return runCatching { val deviceId = context.getAppScopeDeviceId() @@ -61,7 +71,7 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } - + suspend fun getNotifications( type: String? = null, cursor: String? = null @@ -71,4 +81,22 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } + + suspend fun checkNotification(notificationId: Int): Result { + return runCatching { + val request = NotificationCheckRequest(notificationId = notificationId) + val response = notificationService.checkNotification(request) + val result = response.handleBaseResponse().getOrNull() + + // 알림 읽기 성공 시 다른 ViewModel들에게 알림 + if (result != null) { + _notificationUpdateFlow.emit(notificationId) + } + result + } + } + + suspend fun onNotificationReceived() { + _notificationRefreshFlow.emit(Unit) + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt b/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt index f5d06773..db0575ef 100644 --- a/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/NotificationService.kt @@ -3,8 +3,10 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.notification.request.FcmTokenRequest import com.texthip.thip.data.model.notification.request.FcmTokenDeleteRequest import com.texthip.thip.data.model.notification.request.NotificationEnabledRequest +import com.texthip.thip.data.model.notification.request.NotificationCheckRequest import com.texthip.thip.data.model.notification.response.NotificationEnabledResponse import com.texthip.thip.data.model.notification.response.NotificationListResponse +import com.texthip.thip.data.model.notification.response.NotificationCheckResponse import com.texthip.thip.data.model.base.BaseResponse import retrofit2.http.Body import retrofit2.http.DELETE @@ -18,25 +20,30 @@ interface NotificationService { suspend fun registerFcmToken( @Body request: FcmTokenRequest ): BaseResponse - + @GET("users/notification-settings") suspend fun getNotificationEnableState( @Query("deviceId") deviceId: String ): BaseResponse - + @PATCH("notifications/enable-state") suspend fun updateNotificationEnabled( @Body request: NotificationEnabledRequest ): BaseResponse - + @DELETE("notifications/fcm-tokens") suspend fun deleteFcmToken( @Body request: FcmTokenDeleteRequest ): BaseResponse - + @GET("notifications") suspend fun getNotifications( @Query("cursor") cursor: String? = null, @Query("type") type: String? = null ): BaseResponse + + @POST("notifications/check") + suspend fun checkNotification( + @Body request: NotificationCheckRequest + ): BaseResponse } \ No newline at end of file From 613a333861e9b1dcc7d534c47c503d89acfab9e4 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 17:10:50 +0900 Subject: [PATCH 12/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20viewModel,=20UiState=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=ED=98=84=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmpage/viewmodel/AlarmUiState.kt | 1 + .../alarmpage/viewmodel/AlarmViewModel.kt | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt index 4c8fb8ba..24e685ae 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt @@ -12,4 +12,5 @@ data class AlarmUiState( val error: String? = null ) { val canLoadMore: Boolean get() = !isLoading && !isLoadingMore && hasMore + val hasUnreadNotifications: Boolean get() = notifications.any { !it.isChecked } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt index 71a2239a..09547832 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt @@ -3,6 +3,7 @@ package com.texthip.thip.ui.common.alarmpage.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.texthip.thip.data.repository.NotificationRepository +import com.texthip.thip.data.model.notification.response.NotificationCheckResponse import com.texthip.thip.ui.common.alarmpage.mock.NotificationType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -29,6 +30,20 @@ class AlarmViewModel @Inject constructor( init { loadNotifications(reset = true) + + // Repository의 알림 업데이트 이벤트 구독 + viewModelScope.launch { + repository.notificationUpdateFlow.collect { notificationId -> + updateNotificationAsRead(notificationId) + } + } + + // 푸시 알림 도착 시 새로고침 이벤트 구독 + viewModelScope.launch { + repository.notificationRefreshFlow.collect { + refreshData() + } + } } fun loadNotifications(reset: Boolean = false) { @@ -103,4 +118,31 @@ class AlarmViewModel @Inject constructor( loadNotifications(reset = true) } } + + fun checkNotification(notificationId: Int, onNavigate: (NotificationCheckResponse) -> Unit) { + viewModelScope.launch { + repository.checkNotification(notificationId) + .onSuccess { response -> + response?.let { + // 로컬 상태에서 해당 알림을 읽음으로 표시 + updateNotificationAsRead(notificationId) + onNavigate(it) + } + } + .onFailure { exception -> + updateState { it.copy(error = exception.message) } + } + } + } + + private fun updateNotificationAsRead(notificationId: Int) { + val updatedNotifications = uiState.value.notifications.map { notification -> + if (notification.notificationId == notificationId) { + notification.copy(isChecked = true) + } else { + notification + } + } + updateState { it.copy(notifications = updatedNotifications) } + } } \ No newline at end of file From ee3788c135d6473db0110080d553952a745ce1c3 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:35:45 +0900 Subject: [PATCH 13/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=20Screen?= =?UTF-8?q?=EC=97=90=20=EB=A1=9C=EC=A7=81=20=EC=97=B0=EA=B2=B0=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/common/alarmpage/screen/AlarmScreen.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt index 3b0dd18a..f2d76254 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/screen/AlarmScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R +import com.texthip.thip.data.model.notification.response.NotificationCheckResponse import com.texthip.thip.data.model.notification.response.NotificationResponse import com.texthip.thip.ui.common.alarmpage.component.AlarmFilterRow import com.texthip.thip.ui.common.alarmpage.component.CardAlarm @@ -41,6 +42,7 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun AlarmScreen( onNavigateBack: () -> Unit = {}, + onNotificationNavigation: (NotificationCheckResponse) -> Unit = {}, viewModel: AlarmViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -54,7 +56,12 @@ fun AlarmScreen( onNavigateBack = onNavigateBack, onRefresh = { viewModel.refreshData() }, onLoadMore = { viewModel.loadMoreNotifications() }, - onChangeNotificationType = { viewModel.changeNotificationType(it) } + onChangeNotificationType = { viewModel.changeNotificationType(it) }, + onNotificationClick = { notificationId -> + viewModel.checkNotification(notificationId) { response -> + onNotificationNavigation(response) + } + } ) } @@ -65,7 +72,8 @@ fun AlarmContent( onNavigateBack: () -> Unit = {}, onRefresh: () -> Unit = {}, onLoadMore: () -> Unit = {}, - onChangeNotificationType: (NotificationType) -> Unit = {} + onChangeNotificationType: (NotificationType) -> Unit = {}, + onNotificationClick: (Int) -> Unit = {} ) { val listState = rememberLazyListState() @@ -158,7 +166,7 @@ fun AlarmContent( timeAgo = notification.postDate, isRead = notification.isChecked, onClick = { - // TODO: 알림 읽음 처리 + onNotificationClick(notification.notificationId) } ) } From cb9b2a507d60172b512637beaa568a220cb2da33 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:37:39 +0900 Subject: [PATCH 14/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=83=9D=EC=84=B1=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationNavigationExtensions.kt | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/ui/navigator/extensions/NotificationNavigationExtensions.kt diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/NotificationNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/NotificationNavigationExtensions.kt new file mode 100644 index 00000000..a29fd862 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/NotificationNavigationExtensions.kt @@ -0,0 +1,66 @@ +package com.texthip.thip.ui.navigator.extensions + +import android.util.Log +import androidx.navigation.NavController +import com.texthip.thip.data.model.notification.response.NotificationCheckResponse +import com.texthip.thip.data.model.notification.response.NotificationRoute +import com.texthip.thip.ui.navigator.routes.FeedRoutes +import com.texthip.thip.ui.navigator.routes.GroupRoutes +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +private fun JsonElement.toStringOrNull(): String? { + return (this as? JsonPrimitive)?.contentOrNull +} + +fun NavController.navigateFromNotification(response: NotificationCheckResponse) { + val params = response.params + + try { + when (response.route) { + NotificationRoute.FEED_USER -> { + val userId = params["userId"]?.toStringOrNull()?.toLongOrNull() + if (userId != null) { + navigate(FeedRoutes.Others(userId)) + } + } + + NotificationRoute.FEED_DETAIL -> { + val feedId = params["feedId"]?.toStringOrNull()?.toLongOrNull() + if (feedId != null) { + navigate(FeedRoutes.Comment(feedId)) + } + } + + NotificationRoute.ROOM_MAIN -> { + val roomId = params["roomId"]?.toStringOrNull()?.toIntOrNull() + if (roomId != null) { + navigate(GroupRoutes.Room(roomId)) + } + } + + NotificationRoute.ROOM_DETAIL -> { + val roomId = params["roomId"]?.toStringOrNull()?.toIntOrNull() + if (roomId != null) { + navigate(GroupRoutes.Recruit(roomId)) + } + } + + NotificationRoute.ROOM_POST_DETAIL -> { + val roomId = params["roomId"]?.toStringOrNull()?.toIntOrNull() + val page = params["page"]?.toStringOrNull()?.toIntOrNull() + val postId = params["postId"]?.toStringOrNull()?.toIntOrNull() + val postType = params["postType"]?.toStringOrNull() + val openComments = + params["openComments"]?.toStringOrNull()?.toBooleanStrictOrNull() ?: false + + if (roomId != null && page != null) { + navigate(GroupRoutes.Note(roomId, page, openComments, false, postId)) + } + } + } + } catch (e: Exception) { + Log.e("NotificationNav", "Navigation failed", e) + } +} \ No newline at end of file From f7bd19d66c77f36a6cfdc5822c0351c6808ac3d2 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:38:29 +0900 Subject: [PATCH 15/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EA=B8=B0=EC=A1=B4=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MyFirebaseMessagingService.kt | 58 ++++++++++++++----- .../extensions/GroupNavigationExtensions.kt | 28 ++++++--- .../navigator/navigations/GroupNavigation.kt | 24 +++----- .../thip/ui/navigator/routes/GroupRoutes.kt | 6 +- 4 files changed, 77 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt b/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt index ea33b3a0..efed06e4 100644 --- a/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt +++ b/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt @@ -11,6 +11,7 @@ import com.google.firebase.messaging.RemoteMessage import com.texthip.thip.MainActivity import com.texthip.thip.R import com.texthip.thip.data.manager.FcmTokenManager +import com.texthip.thip.data.repository.NotificationRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,6 +23,9 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var fcmTokenManager: FcmTokenManager + @Inject + lateinit var notificationRepository: NotificationRepository + companion object { private const val TAG = "FCM" private const val CHANNEL_ID = "thip_notifications" @@ -32,16 +36,27 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) - Log.d(TAG, "From: ${remoteMessage.from}") - if (remoteMessage.data.isNotEmpty()) { - Log.d(TAG, "Message data payload: ${remoteMessage.data}") + // 푸시 알림 도착 시 알림 상태 새로고침 + CoroutineScope(Dispatchers.IO).launch { + try { + notificationRepository.onNotificationReceived() + } catch (e: Exception) { + Log.e(TAG, "Failed to trigger notification refresh", e) + } } - remoteMessage.notification?.let { - Log.d(TAG, "Message Notification Body: ${it.body}") - showNotification(it.title, it.body) + // Data payload 처리 + val dataPayload = remoteMessage.data + if (dataPayload.isNotEmpty()) { + Log.d(TAG, "Message data payload: $dataPayload") } + + val title = remoteMessage.notification?.title ?: "THIP" + val body = remoteMessage.notification?.body ?: "새로운 알림이 있습니다" + + Log.d(TAG, "App is in foreground, showing custom notification: title=$title, body=$body") + showNotification(title, body, dataPayload) } override fun onNewToken(token: String) { @@ -54,39 +69,56 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } } - private fun showNotification(title: String?, messageBody: String?) { + private fun showNotification( + title: String?, + messageBody: String?, + dataPayload: Map + ) { val intent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + // FCM 데이터를 Intent에 추가 + dataPayload["notificationId"]?.let { notificationId -> + putExtra("notification_id", notificationId) + putExtra("from_notification", true) + } } val pendingIntent = PendingIntent.getActivity( this, - 0, + System.currentTimeMillis().toInt(), // 고유한 requestCode 사용 intent, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) createNotificationChannel() val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_foreground) + .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title ?: "THIP") .setContentText(messageBody) .setAutoCancel(true) .setContentIntent(pendingIntent) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(0, notificationBuilder.build()) + val notificationId = + dataPayload["notificationId"]?.toIntOrNull() ?: System.currentTimeMillis().toInt() + notificationManager.notify(notificationId, notificationBuilder.build()) } private fun createNotificationChannel() { val channel = NotificationChannel( CHANNEL_ID, CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT + NotificationManager.IMPORTANCE_HIGH ).apply { description = CHANNEL_DESCRIPTION + enableVibration(true) + setShowBadge(true) + enableLights(true) } val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt index 864247c9..85a5ccec 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt @@ -23,12 +23,14 @@ fun NavHostController.navigateToGroupMakeRoomWithBook( imageUrl: String, author: String ) { - navigate(GroupRoutes.MakeRoomWithBook( - isbn = isbn, - title = title, - imageUrl = imageUrl, - author = author - )) + navigate( + GroupRoutes.MakeRoomWithBook( + isbn = isbn, + title = title, + imageUrl = imageUrl, + author = author + ) + ) } // 완료된 모임방 목록으로 이동 @@ -90,9 +92,19 @@ fun NavHostController.navigateToGroupNote( roomId: Int, page: Int? = null, isOverview: Boolean? = null, - isExpired: Boolean = false + isExpired: Boolean = false, + postId: Int? = null ) { - navigate(GroupRoutes.Note(roomId = roomId, page = page, isOverview = isOverview, isExpired = isExpired)) + navigate( + GroupRoutes.Note( + roomId = roomId, + page = page, + openComments = false, + isExpired = isExpired, + postId = postId, + isOverview = isOverview + ) + ) } // 기록 생성 화면으로 이동 diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt index b6ba059f..2f73ad51 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt @@ -342,30 +342,23 @@ fun NavGraphBuilder.groupNavigation( val route = backStackEntry.toRoute() val roomId = route.roomId val page = route.page + val openComments = route.openComments val isOverview = route.isOverview val isExpired = route.isExpired + val postId = route.postId val result = backStackEntry.savedStateHandle.get("selected_tab_index") val viewModel: GroupNoteViewModel = hiltViewModel(backStackEntry) - val feedViewModel: FeedViewModel = - hiltViewModel(navController.getBackStackEntry(MainTabRoutes.Group)) - val feedUiState by feedViewModel.uiState.collectAsState() - val myUserId = feedUiState.myFeedInfo?.creatorId - - LaunchedEffect(Unit) { - if (feedUiState.myFeedInfo == null) { - feedViewModel.onTabSelected(1) - } - } - GroupNoteScreen( roomId = roomId, resultTabIndex = result, initialPage = page, initialIsOverview = isOverview, isExpired = isExpired, + initialPostId = postId, + openComments = openComments, onResultConsumed = { backStackEntry.savedStateHandle.remove("selected_tab_index") }, @@ -424,11 +417,10 @@ fun NavGraphBuilder.groupNavigation( ) }, onNavigateToUserProfile = { userId -> - if (myUserId != null && myUserId == userId) { - navController.navigate(FeedRoutes.My) - } else { - navController.navigate(FeedRoutes.Others(userId)) - } + navController.navigate(FeedRoutes.Others(userId)) + }, + onNavigateToMyProfile = { + navController.navigate(FeedRoutes.My) }, viewModel = viewModel ) diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt index 723054e0..ddc9eb09 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt @@ -43,8 +43,10 @@ sealed class GroupRoutes : Routes() { data class Note( val roomId: Int, val page: Int? = null, - val isOverview: Boolean? = null, - val isExpired: Boolean = false + val openComments: Boolean = false, + val isExpired: Boolean = false, + val postId: Int? = null, + val isOverview: Boolean? = null ) : GroupRoutes() @Serializable From 28a0187f0b8b2da0d721a8adad8ddff0bbe017e5 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:43:43 +0900 Subject: [PATCH 16/29] =?UTF-8?q?[feat]:=20=ED=91=B8=EC=8B=9C=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20MainActivity=EC=99=80=20MainSc?= =?UTF-8?q?reen=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/texthip/thip/MainActivity.kt | 95 ++++++++++++++++++- .../main/java/com/texthip/thip/MainScreen.kt | 75 ++++++++++++++- 2 files changed, 164 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/MainActivity.kt b/app/src/main/java/com/texthip/thip/MainActivity.kt index 3d86934d..83d1f2e6 100644 --- a/app/src/main/java/com/texthip/thip/MainActivity.kt +++ b/app/src/main/java/com/texthip/thip/MainActivity.kt @@ -1,12 +1,17 @@ package com.texthip.thip +import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -32,21 +37,96 @@ class MainActivity : ComponentActivity() { ActivityResultContracts.RequestPermission() ) {} + private var notificationData by mutableStateOf(null) + + data class NotificationData( + val notificationId: String?, + val fromNotification: Boolean + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - + // 앱 시작 시 알림 권한 요청 requestNotificationPermissionIfNeeded() - + + // 푸시 알림에서 온 데이터 처리 + handleNotificationIntent(intent) + setContent { ThipTheme { - RootNavHost(authStateManager) + RootNavHost( + authStateManager = authStateManager, + notificationData = notificationData + ) } } // getKakaoKeyHash(this) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + // 새로운 Intent가 들어올 때 (백그라운드에서 알림 클릭 시) + handleNotificationIntent(intent) + } + + private fun handleNotificationIntent(intent: Intent) { + Log.d("MainActivity", "Handling notification intent with extras: ${intent.extras?.keySet()}") + + val customNotificationId = intent.getStringExtra("notification_id") + val customFromNotification = intent.getBooleanExtra("from_notification", false) + + // FCM 백그라운드 알림에서 온 데이터 확인 (시스템이 자동 생성한 알림의 경우) + val fcmNotificationId = intent.getStringExtra("gcm.notification.data.notificationId") + ?: intent.getStringExtra("notificationId") + + var newNotificationData: NotificationData? = null + + // 커스텀 알림에서 온 경우 (포그라운드에서 생성된 알림) + if (customFromNotification && customNotificationId != null) { + Log.d("MainActivity", "Processing custom notification: $customNotificationId") + newNotificationData = NotificationData(customNotificationId, customFromNotification) + + // Intent extras 완전 제거 + cleanupNotificationExtras(intent, listOf("notification_id", "from_notification")) + } + // FCM 백그라운드 시스템 알림에서 온 경우 + else if (fcmNotificationId != null) { + Log.d("MainActivity", "Processing FCM notification: $fcmNotificationId") + newNotificationData = NotificationData(fcmNotificationId, true) + + // Intent extras 완전 제거 + cleanupNotificationExtras(intent, listOf( + "gcm.notification.data.notificationId", + "notificationId" + )) + } + + // 새로운 알림 데이터가 있고, 기존 데이터와 다른 경우에만 업데이트 + if (newNotificationData != null && newNotificationData != notificationData) { + Log.d("MainActivity", "Setting new notification data: ${newNotificationData.notificationId}") + notificationData = newNotificationData + } else if (newNotificationData != null) { + Log.d("MainActivity", "Notification data unchanged, skipping update") + } + } + + private fun cleanupNotificationExtras(intent: Intent, keys: List) { + keys.forEach { key -> + try { + intent.removeExtra(key) + Log.v("MainActivity", "Removed extra: $key") + } catch (e: Exception) { + Log.w("MainActivity", "Failed to remove extra: $key", e) + } + } + + // Intent 플래그도 정리 + intent.replaceExtras(intent.extras) + } + private fun requestNotificationPermissionIfNeeded() { if (NotificationPermissionUtils.shouldRequestNotificationPermission(this)) { notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) @@ -55,7 +135,10 @@ class MainActivity : ComponentActivity() { } @Composable -fun RootNavHost(authStateManager: AuthStateManager) { +fun RootNavHost( + authStateManager: AuthStateManager, + notificationData: MainActivity.NotificationData? = null +) { val navController = rememberNavController() LaunchedEffect(Unit) { @@ -66,6 +149,7 @@ fun RootNavHost(authStateManager: AuthStateManager) { } } + NavHost( navController = navController, startDestination = CommonRoutes.Splash @@ -104,7 +188,8 @@ fun RootNavHost(authStateManager: AuthStateManager) { inclusive = true } } - } + }, + notificationData = notificationData ) } } diff --git a/app/src/main/java/com/texthip/thip/MainScreen.kt b/app/src/main/java/com/texthip/thip/MainScreen.kt index 77084060..4552dc5a 100644 --- a/app/src/main/java/com/texthip/thip/MainScreen.kt +++ b/app/src/main/java/com/texthip/thip/MainScreen.kt @@ -3,28 +3,101 @@ package com.texthip.thip import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import android.util.Log import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.texthip.thip.data.repository.NotificationRepository import com.texthip.thip.ui.navigator.BottomNavigationBar import com.texthip.thip.ui.navigator.MainNavHost import com.texthip.thip.ui.navigator.extensions.isMainTabRoute +import com.texthip.thip.ui.navigator.extensions.navigateFromNotification import com.texthip.thip.ui.navigator.routes.MainTabRoutes +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface MainScreenEntryPoint { + fun notificationRepository(): NotificationRepository +} @Composable fun MainScreen( - onNavigateToLogin: () -> Unit + onNavigateToLogin: () -> Unit, + notificationData: MainActivity.NotificationData? = null ) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination var feedReselectionTrigger by remember { mutableStateOf(0) } + val context = LocalContext.current + + // 처리된 알림 ID 추적 (중복 처리 방지) + var processedNotificationId by remember { mutableStateOf(null) } + + // 푸시 알림에서 온 경우 알림 읽기 API 호출 및 네비게이션 + LaunchedEffect(notificationData?.notificationId) { + val data = notificationData + + // 중복 처리 방지: 이미 처리한 알림이면 스킵 + if (data?.notificationId == processedNotificationId) { + Log.d("MainScreen", "Notification already processed: ${data.notificationId}") + return@LaunchedEffect + } + + data?.let { notificationData -> + if (notificationData.fromNotification && notificationData.notificationId != null) { + Log.d("MainScreen", "Processing notification: ${notificationData.notificationId}") + + try { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + MainScreenEntryPoint::class.java + ) + val notificationRepository = entryPoint.notificationRepository() + + // 알림 ID를 Int로 변환 시도 + val notificationId = try { + notificationData.notificationId.toInt() + } catch (e: NumberFormatException) { + Log.e("MainScreen", "Invalid notification ID format: ${notificationData.notificationId}", e) + return@LaunchedEffect + } + + val result = notificationRepository.checkNotification(notificationId) + + result.onSuccess { response -> + if (response != null) { + Log.d("MainScreen", "Notification check successful, navigating to: ${response.route}") + navController.navigateFromNotification(response) + // 알림 상태 강제 새로고침 트리거 + notificationRepository.onNotificationReceived() + // 처리 완료 표시 + processedNotificationId = notificationData.notificationId + } else { + Log.w("MainScreen", "Notification check returned null response") + } + }.onFailure { exception -> + Log.e("MainScreen", "Failed to check notification: ${notificationData.notificationId}", exception) + } + + } catch (e: Exception) { + Log.e("MainScreen", "Unexpected error processing notification: ${notificationData.notificationId}", e) + } + } + } + } val showBottomBar = currentDestination?.isMainTabRoute() ?: true From 4d53c6c947ca5642e87b17772f42b1793c78bab1 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:44:17 +0900 Subject: [PATCH 17/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=EC=9D=84=20?= =?UTF-8?q?=EC=9D=BD=EA=B3=A0=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=99=94=EC=9D=84=EB=95=8C=20=EC=95=8C=EB=A6=BC=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=88=98=EC=A0=95=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/texthip/thip/ui/feed/screen/FeedScreen.kt | 11 ++++++----- .../com/texthip/thip/ui/group/screen/GroupScreen.kt | 10 ++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt index 96f7b270..186fcb14 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt @@ -155,7 +155,8 @@ fun FeedScreen( val hasUpdatedFeedData = navController.currentBackStackEntry?.savedStateHandle?.get("updated_feed_id") != null val fromProfile = - navController.currentBackStackEntry?.savedStateHandle?.get("from_profile") ?: false + navController.currentBackStackEntry?.savedStateHandle?.get("from_profile") + ?: false if (!hasUpdatedFeedData && !fromProfile) { // 일반적인 경우: 전체 새로고침 + 스크롤 상단 이동 @@ -178,7 +179,7 @@ fun FeedScreen( isUserTabChange = false } } - + // 같은 탭 재클릭 시 스크롤 상단 이동 처리 LaunchedEffect(shouldScrollToTop) { if (shouldScrollToTop) { @@ -186,7 +187,7 @@ fun FeedScreen( shouldScrollToTop = false } } - + // 중복된 로직 제거 - 기존 bottomNavReselected 방식만 사용 LaunchedEffect(resultFeedId) { @@ -218,7 +219,7 @@ fun FeedScreen( } } } - + // 바텀 네비게이션 탭 재선택 처리 (직접 상태 전달 방식) LaunchedEffect(onFeedTabReselected) { if (onFeedTabReselected > 0) { @@ -257,7 +258,7 @@ fun FeedScreen( FeedContent( feedUiState = feedUiState, - hasUnreadNotifications = alarmUiState.notifications.any { !it.isChecked }, + hasUnreadNotifications = alarmUiState.hasUnreadNotifications, showProgressBar = showProgressBar, progress = progress.value, currentListState = currentListState, diff --git a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt index 99bd4486..2cfd8ff1 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt @@ -70,7 +70,7 @@ fun GroupScreen( GroupContent( uiState = uiState, - hasUnreadNotifications = alarmUiState.notifications.any { !it.isChecked }, + hasUnreadNotifications = alarmUiState.hasUnreadNotifications, onNavigateToMakeRoom = onNavigateToMakeRoom, onNavigateToGroupDone = onNavigateToGroupDone, onNavigateToAlarm = onNavigateToAlarm, @@ -141,7 +141,13 @@ fun GroupContent( groupCards = uiState.myJoinedRooms, userName = uiState.userName, onCardClick = { joinedRoom -> - onNavigateToGroupRoom(joinedRoom.roomId) + if (joinedRoom.deadlineDate == null) { + // 시작 후 + onNavigateToGroupRoom(joinedRoom.roomId) + } else { + // 시작 전 + onNavigateToGroupRecruit(joinedRoom.roomId) + } }, onCardVisible = onCardVisible ) From c1a877c60ce9b5379ea0e50c4c885d36ab5e43d1 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:44:49 +0900 Subject: [PATCH 18/29] =?UTF-8?q?[feat]:=20=ED=95=B4=EB=8B=B9=20=ED=8F=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EA=B3=BC=20=EB=8C=93=EA=B8=80=EC=B0=BD=20=EC=97=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=9C=84=ED=95=9C=20GroupNote=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/group/note/screen/GroupNoteScreen.kt | 81 ++++++++++++++++++- .../note/viewmodel/GroupNoteViewModel.kt | 18 ++++- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt index 0166a32e..add51661 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.texthip.thip.R +import com.texthip.thip.ui.feed.viewmodel.FeedViewModel import com.texthip.thip.data.model.rooms.response.PostList import com.texthip.thip.data.model.rooms.response.RoomsRecordsPinResponse import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet @@ -82,15 +83,30 @@ fun GroupNoteScreen( onEditNoteClick: (post: PostList) -> Unit = {}, onEditVoteClick: (post: PostList) -> Unit = {}, onNavigateToUserProfile: (userId: Long) -> Unit = {}, + onNavigateToMyProfile: () -> Unit = {}, resultTabIndex: Int? = null, onResultConsumed: () -> Unit = {}, initialPage: Int? = null, initialIsOverview: Boolean? = null, + initialPostId: Int? = null, + openComments: Boolean = false, viewModel: GroupNoteViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + // FeedViewModel을 통해 현재 사용자 정보 가져오기 + val feedViewModel: FeedViewModel = hiltViewModel() + val feedUiState by feedViewModel.uiState.collectAsStateWithLifecycle() + val currentUserId = feedUiState.myFeedInfo?.creatorId + + // 내 피드 정보가 없으면 로드 + LaunchedEffect(Unit) { + if (feedUiState.myFeedInfo == null) { + feedViewModel.onTabSelected(1) + } + } + var showProgressBar by remember { mutableStateOf(false) } val progress = remember { Animatable(0f) } var progressJob by remember { mutableStateOf(null) } @@ -128,7 +144,7 @@ fun GroupNoteScreen( LaunchedEffect(key1 = roomId) { // 기록 생성 후 돌아온 경우가 아닐 때 (처음 진입 시) 초기화 if (resultTabIndex == null) { - viewModel.initialize(roomId, initialPage, initialIsOverview) + viewModel.initialize(roomId, initialPage, initialIsOverview, initialPostId) } } @@ -159,9 +175,19 @@ fun GroupNoteScreen( }, onEditNoteClick = onEditNoteClick, onEditVoteClick = onEditVoteClick, - onNavigateToUserProfile = onNavigateToUserProfile, + onNavigateToUserProfile = { userId -> + // 현재 사용자 ID와 비교하여 적절한 네비게이션 수행 + if (currentUserId != null && currentUserId == userId) { + // 내 프로필로 이동 + onNavigateToMyProfile() + } else { + // 다른 사용자 프로필로 이동 + onNavigateToUserProfile(userId) + } + }, showProgressBar = showProgressBar, - progress = progress.value + progress = progress.value, + openComments = openComments ) } @@ -177,7 +203,8 @@ fun GroupNoteContent( onEditVoteClick: (post: PostList) -> Unit, onNavigateToUserProfile: (userId: Long) -> Unit, showProgressBar: Boolean, - progress: Float + progress: Float, + openComments: Boolean = false ) { var isCommentBottomSheetVisible by remember { mutableStateOf(false) } var selectedPostForComment by remember { mutableStateOf(null) } @@ -230,6 +257,52 @@ fun GroupNoteContent( } } + // 특정 포스트로 스크롤 + LaunchedEffect(uiState.scrollToPostId, uiState.posts, uiState.isLoading) { + val scrollToPostId = uiState.scrollToPostId + + if (scrollToPostId != null && uiState.posts.isNotEmpty() && !uiState.isLoading) { + val targetIndex = uiState.posts.indexOfFirst { it.postId == scrollToPostId } + + if (targetIndex != -1) { + val targetPost = uiState.posts[targetIndex] + + // 헤더 아이템들을 고려한 실제 인덱스 계산 + val actualIndex = if (uiState.selectedTabIndex == 0) { + targetIndex + 2 // 정보 텍스트 + 프로그레스바 아이템 + } else { + targetIndex + 1 // 프로그레스바 아이템만 + } + + // LazyColumn이 완전히 구성될 때까지 잠시 대기 + kotlinx.coroutines.delay(100) + + try { + listState.animateScrollToItem(actualIndex) + + // openComments가 true이면 댓글 버텀시트를 자동으로 열기 + if (openComments) { + kotlinx.coroutines.delay(200) // 스크롤 완료 후 잠시 대기 + selectedPostForComment = targetPost + isCommentBottomSheetVisible = true + } + } catch (e: Exception) { + // 애니메이션이 실패하면 일반 스크롤 시도 + listState.scrollToItem(actualIndex) + + // openComments가 true이면 댓글 버텀시트를 자동으로 열기 + if (openComments) { + kotlinx.coroutines.delay(200) // 스크롤 완료 후 잠시 대기 + selectedPostForComment = targetPost + isCommentBottomSheetVisible = true + } + } + + onEvent(GroupNoteEvent.ClearScrollTarget) + } + } + } + Box( if (isOverlayVisible) { Modifier diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt index bf2cc03a..018dcaaa 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt @@ -39,7 +39,10 @@ data class GroupNoteUiState( val pageEnd: String = "", val isOverview: Boolean = false, val isPageFilter: Boolean = false, - val totalEnabled: Boolean = false + val totalEnabled: Boolean = false, + + // 스크롤 관련 상태 + val scrollToPostId: Int? = null ) sealed interface GroupNoteSideEffect { @@ -62,6 +65,7 @@ sealed interface GroupNoteEvent { data class OnLikeRecord(val postId: Int, val postType: String) : GroupNoteEvent data class OnPinRecord(val recordId: Int, val content: String) : GroupNoteEvent data object RefreshPosts : GroupNoteEvent + data object ClearScrollTarget : GroupNoteEvent } @@ -82,7 +86,8 @@ class GroupNoteViewModel @Inject constructor( fun initialize( roomId: Int, initialPage: Int? = null, - initialIsOverview: Boolean? = null + initialIsOverview: Boolean? = null, + initialPostId: Int? = null ) { this.roomId = roomId @@ -97,6 +102,12 @@ class GroupNoteViewModel @Inject constructor( } } + if (initialPostId != null) { + _uiState.update { + it.copy(scrollToPostId = initialPostId) + } + } + refreshAllData() } @@ -168,6 +179,9 @@ class GroupNoteViewModel @Inject constructor( is GroupNoteEvent.OnLikeRecord -> likeRecord(event.postId, event.postType) is GroupNoteEvent.RefreshPosts -> loadPosts(isRefresh = true) is GroupNoteEvent.OnPinRecord -> pinRecord(event.recordId, event.content) + GroupNoteEvent.ClearScrollTarget -> { + _uiState.update { it.copy(scrollToPostId = null) } + } else -> { Log.w("GroupNoteViewModel", "Unhandled event received: $event") } From e74142c833ffebc101737bec158e18654cd62365 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:45:12 +0900 Subject: [PATCH 19/29] =?UTF-8?q?[feat]:=20AlarmScreen=EC=9D=98=20Navigati?= =?UTF-8?q?on=20=EC=BD=9C=EB=B0=B1=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/navigator/navigations/CommonNavigation.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt index 395eb3cc..7885bf5e 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.composable import com.texthip.thip.ui.common.alarmpage.screen.AlarmScreen import com.texthip.thip.ui.common.screen.RegisterBookScreen +import com.texthip.thip.ui.navigator.extensions.navigateFromNotification import com.texthip.thip.ui.navigator.routes.CommonRoutes // Common 관련 네비게이션 @@ -15,10 +16,13 @@ fun NavGraphBuilder.commonNavigation( // Alarm 화면 composable { AlarmScreen( - onNavigateBack = navigateBack + onNavigateBack = navigateBack, + onNotificationNavigation = { response -> + navController.navigateFromNotification(response) + } ) } - + // 책 요청 화면 composable { RegisterBookScreen( From 3ed58f2ce651ed8a5119fb9769f998ebee08e051 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:45:27 +0900 Subject: [PATCH 20/29] =?UTF-8?q?[feat]:=20=EB=A7=A4=EB=8B=88=ED=8E=98?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 774bc0ad..af348c36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,16 @@ android:name="com.kakao.sdk.AppKey" android:value="${NATIVE_APP_KEY}" /> + + + + + + @@ -39,20 +49,21 @@ - - + android:exported="false" + android:directBootAware="true"> + From e0f21117d72a2f8bd11e1219a24c46a265de8fac Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:57:59 +0900 Subject: [PATCH 21/29] =?UTF-8?q?[feat]:=20=ED=91=B8=EC=8B=9C=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=ED=97=88=EC=9A=A9=EC=97=90=EC=84=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B6=8C=ED=95=9C=EC=9D=B4=20=EC=97=86=EB=8B=A4?= =?UTF-8?q?=EB=A9=B4=20=EA=B6=8C=ED=95=9C=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/texthip/thip/MainScreen.kt | 2 +- .../screen/MypageNotificationEditScreen.kt | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/MainScreen.kt b/app/src/main/java/com/texthip/thip/MainScreen.kt index 4552dc5a..d3710e6d 100644 --- a/app/src/main/java/com/texthip/thip/MainScreen.kt +++ b/app/src/main/java/com/texthip/thip/MainScreen.kt @@ -52,7 +52,7 @@ fun MainScreen( // 중복 처리 방지: 이미 처리한 알림이면 스킵 if (data?.notificationId == processedNotificationId) { - Log.d("MainScreen", "Notification already processed: ${data.notificationId}") + Log.d("MainScreen", "Notification already processed: ${data?.notificationId}") return@LaunchedEffect } diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt index c3e15d00..644ca173 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt @@ -1,5 +1,7 @@ package com.texthip.thip.ui.mypage.screen +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically @@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +39,7 @@ import com.texthip.thip.ui.mypage.viewmodel.MypageNotificationEditUiState import com.texthip.thip.ui.mypage.viewmodel.MypageNotificationEditViewModel import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import com.texthip.thip.utils.permission.NotificationPermissionUtils import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Date @@ -46,10 +50,30 @@ fun MyPageNotificationEditScreen( onNavigateBack: () -> Unit, viewModel: MypageNotificationEditViewModel = hiltViewModel() ) { + val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() var toastMessage by rememberSaveable { mutableStateOf(null) } var toastDateTime by rememberSaveable { mutableStateOf("") } + // 알림 권한 요청 런처 + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + if (isGranted) { + // 권한이 허용되면 알림 활성화 + viewModel.onNotificationToggle(true) + toastMessage = "push_on" + val dateFormat = SimpleDateFormat("yyyy년 M월 d일 H시 m분", Locale.KOREAN) + toastDateTime = dateFormat.format(Date()) + } else { + // 권한이 거부되면 토스트 메시지 표시 + toastMessage = "permission_denied" + val dateFormat = SimpleDateFormat("yyyy년 M월 d일 H시 m분", Locale.KOREAN) + toastDateTime = dateFormat.format(Date()) + } + } + ) + LaunchedEffect(toastMessage) { if (toastMessage != null) { delay(3000) @@ -63,10 +87,25 @@ fun MyPageNotificationEditScreen( toastDateTime = toastDateTime, onNavigateBack = onNavigateBack, onNotificationToggle = { enabled -> - viewModel.onNotificationToggle(enabled) - toastMessage = if (enabled) "push_on" else "push_off" - val dateFormat = SimpleDateFormat("yyyy년 M월 d일 H시 m분", Locale.KOREAN) - toastDateTime = dateFormat.format(Date()) + if (enabled) { + // 알림을 켜려고 할 때 권한 확인 + if (NotificationPermissionUtils.shouldRequestNotificationPermission(context)) { + // 권한이 필요하면 권한 요청 + notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + // 권한이 이미 있거나 필요없으면 바로 설정 변경 + viewModel.onNotificationToggle(enabled) + toastMessage = "push_on" + val dateFormat = SimpleDateFormat("yyyy년 M월 d일 H시 m분", Locale.KOREAN) + toastDateTime = dateFormat.format(Date()) + } + } else { + // 알림을 끄는 경우는 권한 체크 없이 바로 설정 변경 + viewModel.onNotificationToggle(enabled) + toastMessage = "push_off" + val dateFormat = SimpleDateFormat("yyyy년 M월 d일 H시 m분", Locale.KOREAN) + toastDateTime = dateFormat.format(Date()) + } } ) } @@ -97,9 +136,12 @@ fun MyPageNotificationEditContent( ) { toastMessage?.let { message -> ToastWithDate( - message = stringResource( - if (message == "push_on") R.string.push_on else R.string.push_off - ), + message = when (message) { + "push_on" -> stringResource(R.string.push_on) + "push_off" -> stringResource(R.string.push_off) + "permission_denied" -> "알림 권한이 필요합니다. 설정에서 권한을 허용해주세요." + else -> stringResource(R.string.push_off) + }, date = toastDateTime, modifier = Modifier.fillMaxWidth() ) From 2bd7010aa0915a005f7493b197966af8a7411edc Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 25 Sep 2025 23:58:17 +0900 Subject: [PATCH 22/29] =?UTF-8?q?[feat]:=20=EC=95=8C=EB=A6=BC=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=EC=9D=B4=20=EC=97=86=EC=96=B4=EB=8F=84=20FCM=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=84=20=EC=A0=84=EC=86=A1=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/texthip/thip/data/manager/FcmTokenManager.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/manager/FcmTokenManager.kt b/app/src/main/java/com/texthip/thip/data/manager/FcmTokenManager.kt index f2ecf52d..58afb36b 100644 --- a/app/src/main/java/com/texthip/thip/data/manager/FcmTokenManager.kt +++ b/app/src/main/java/com/texthip/thip/data/manager/FcmTokenManager.kt @@ -9,7 +9,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey import com.google.firebase.messaging.FirebaseMessaging import com.texthip.thip.data.repository.NotificationRepository import com.texthip.thip.utils.auth.getAppScopeDeviceId -import com.texthip.thip.utils.permission.NotificationPermissionUtils import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -93,12 +92,6 @@ class FcmTokenManager @Inject constructor( } private suspend fun sendTokenToServer(token: String) { - // 알림 권한이 없으면 토큰을 서버에 전송하지 않음 - if (!NotificationPermissionUtils.isNotificationPermissionGranted(context)) { - Log.w("FCM", "Notification permission not granted, skipping token registration") - return - } - val deviceId = context.getAppScopeDeviceId() notificationRepository.registerFcmToken(deviceId, token) .onSuccess { From 5864f8bf067c38587d1ae78c635dd1942e686eaf Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 26 Sep 2025 00:43:00 +0900 Subject: [PATCH 23/29] =?UTF-8?q?[refactor]:=20emit=EC=97=90=EC=84=9C=20tr?= =?UTF-8?q?yEmit=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/NotificationRepository.kt | 21 +++++++++++++------ .../service/MyFirebaseMessagingService.kt | 12 +++++------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt index 3ee59344..bf19870f 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt @@ -12,6 +12,7 @@ import com.texthip.thip.data.model.notification.response.NotificationCheckRespon import com.texthip.thip.data.service.NotificationService import com.texthip.thip.utils.auth.getAppScopeDeviceId import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -23,10 +24,18 @@ class NotificationRepository @Inject constructor( private val notificationService: NotificationService, @param:ApplicationContext private val context: Context ) { - private val _notificationUpdateFlow = MutableSharedFlow() + private val _notificationUpdateFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) val notificationUpdateFlow: SharedFlow = _notificationUpdateFlow.asSharedFlow() - private val _notificationRefreshFlow = MutableSharedFlow() + private val _notificationRefreshFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) val notificationRefreshFlow: SharedFlow = _notificationRefreshFlow.asSharedFlow() suspend fun registerFcmToken( deviceId: String, @@ -88,15 +97,15 @@ class NotificationRepository @Inject constructor( val response = notificationService.checkNotification(request) val result = response.handleBaseResponse().getOrNull() - // 알림 읽기 성공 시 다른 ViewModel들에게 알림 + // 알림 읽기 성공 시 다른 ViewModel들에게 알림 (비차단 emit) if (result != null) { - _notificationUpdateFlow.emit(notificationId) + _notificationUpdateFlow.tryEmit(notificationId) } result } } - suspend fun onNotificationReceived() { - _notificationRefreshFlow.emit(Unit) + fun onNotificationReceived() { + _notificationRefreshFlow.tryEmit(Unit) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt b/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt index efed06e4..b7ecfa29 100644 --- a/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt +++ b/app/src/main/java/com/texthip/thip/service/MyFirebaseMessagingService.kt @@ -37,13 +37,11 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { super.onMessageReceived(remoteMessage) - // 푸시 알림 도착 시 알림 상태 새로고침 - CoroutineScope(Dispatchers.IO).launch { - try { - notificationRepository.onNotificationReceived() - } catch (e: Exception) { - Log.e(TAG, "Failed to trigger notification refresh", e) - } + // 푸시 알림 도착 시 알림 상태 새로고침 (비차단 방식) + try { + notificationRepository.onNotificationReceived() + } catch (e: Exception) { + Log.e(TAG, "Failed to trigger notification refresh", e) } // Data payload 처리 From 0d9f94a353fa98fbd52ea638a8a22007b315787c Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 26 Sep 2025 00:45:49 +0900 Subject: [PATCH 24/29] =?UTF-8?q?[refactor]:=20LaunchedEffect=20=ED=82=A4?= =?UTF-8?q?=EC=97=90fromNotification=20=ED=8F=AC=ED=95=A8=EB=90=98?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/texthip/thip/MainScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/texthip/thip/MainScreen.kt b/app/src/main/java/com/texthip/thip/MainScreen.kt index d3710e6d..362922aa 100644 --- a/app/src/main/java/com/texthip/thip/MainScreen.kt +++ b/app/src/main/java/com/texthip/thip/MainScreen.kt @@ -47,7 +47,7 @@ fun MainScreen( var processedNotificationId by remember { mutableStateOf(null) } // 푸시 알림에서 온 경우 알림 읽기 API 호출 및 네비게이션 - LaunchedEffect(notificationData?.notificationId) { + LaunchedEffect(notificationData?.notificationId, notificationData?.fromNotification) { val data = notificationData // 중복 처리 방지: 이미 처리한 알림이면 스킵 From 801e8badd1d5dfe64ae46af62bb263384c1d2399 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 26 Sep 2025 00:51:33 +0900 Subject: [PATCH 25/29] =?UTF-8?q?[refactor]:=20String=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=20=EB=B0=8F=20PR=20=EB=B0=98=EC=98=81=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmpage/viewmodel/AlarmViewModel.kt | 50 ++++++++++++------- .../screen/MypageNotificationEditScreen.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt index 09547832..8e556d6d 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt @@ -6,6 +6,7 @@ import com.texthip.thip.data.repository.NotificationRepository import com.texthip.thip.data.model.notification.response.NotificationCheckResponse import com.texthip.thip.ui.common.alarmpage.mock.NotificationType import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,6 +24,7 @@ class AlarmViewModel @Inject constructor( private var nextCursor: String? = null private var isLastPage = false private var isLoadingData = false + private var loadJob: Job? = null private fun updateState(update: (AlarmUiState) -> AlarmUiState) { _uiState.value = update(_uiState.value) @@ -47,27 +49,37 @@ class AlarmViewModel @Inject constructor( } fun loadNotifications(reset: Boolean = false) { + // reset 시 기존 작업 취소 + if (reset) { + loadJob?.cancel() + loadJob = null + } + + // 중복 로드 방지 (reset이 아닌 경우에만) if (isLoadingData && !reset) return if (isLastPage && !reset) return - viewModelScope.launch { - try { - isLoadingData = true - - if (reset) { - updateState { - it.copy( - isLoading = true, - notifications = emptyList(), - hasMore = true - ) - } - nextCursor = null - isLastPage = false - } else { - updateState { it.copy(isLoadingMore = true) } - } + // launch 전에 isLoadingData 선반영 (플리커 방지) + isLoadingData = true + + // UI 상태 즉시 반영 + if (reset) { + updateState { + it.copy( + isLoading = true, + notifications = emptyList(), + hasMore = true + ) + } + nextCursor = null + isLastPage = false + } else { + updateState { it.copy(isLoadingMore = true) } + } + // 하나의 loadJob에 작업 바인딩 + loadJob = viewModelScope.launch { + try { val type = if (uiState.value.currentNotificationType == NotificationType.FEED_AND_ROOM) { null @@ -100,6 +112,10 @@ class AlarmViewModel @Inject constructor( } finally { isLoadingData = false updateState { it.copy(isLoading = false, isLoadingMore = false) } + // 작업 완료 시 job 참조 정리 + if (loadJob?.isCompleted == true) { + loadJob = null + } } } } diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt index 644ca173..956f0ad2 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt @@ -139,7 +139,7 @@ fun MyPageNotificationEditContent( message = when (message) { "push_on" -> stringResource(R.string.push_on) "push_off" -> stringResource(R.string.push_off) - "permission_denied" -> "알림 권한이 필요합니다. 설정에서 권한을 허용해주세요." + "permission_denied" -> stringResource(R.string.notification_permission_required) else -> stringResource(R.string.push_off) }, date = toastDateTime, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e5d8af1..4d0c85e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,6 +127,7 @@ 알림센터의 모든 알림을 포함해요 푸시 알림이 해제되었어요. 푸시 알림이 설정되었어요. + 알림 권한이 필요합니다. 설정에서 권한을 허용해주세요. texthip2025@gmail.com 이메일로 닉네임과 문의사항을 보내주시면 From bf47fe1bb7041ed0b00bcc688c3b2d6ebae6884b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 26 Sep 2025 12:47:11 +0900 Subject: [PATCH 26/29] =?UTF-8?q?[refactor]:=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=ED=98=B8=EC=B6=9C=20=EC=88=9C=EC=84=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../texthip/thip/data/repository/NotificationRepository.kt | 4 ++-- .../thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt index bf19870f..e9669fd2 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/NotificationRepository.kt @@ -82,8 +82,8 @@ class NotificationRepository @Inject constructor( } suspend fun getNotifications( - type: String? = null, - cursor: String? = null + cursor: String? = null, + type: String? = null ): Result { return runCatching { val response = notificationService.getNotifications(cursor, type) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt index 8e556d6d..b84945e3 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt @@ -87,7 +87,7 @@ class AlarmViewModel @Inject constructor( uiState.value.currentNotificationType.value } - repository.getNotifications(type, nextCursor) + repository.getNotifications(nextCursor, type) .onSuccess { notificationListResponse -> notificationListResponse?.let { response -> val currentList = From 249c517985becb16e0f907b3665420c79b87978d Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 26 Sep 2025 12:54:03 +0900 Subject: [PATCH 27/29] =?UTF-8?q?[refactor]:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EB=B7=B0=EB=AA=A8=EB=8D=B8=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/common/alarmpage/viewmodel/AlarmViewModel.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt index b84945e3..6b0d540b 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmViewModel.kt @@ -78,7 +78,7 @@ class AlarmViewModel @Inject constructor( } // 하나의 loadJob에 작업 바인딩 - loadJob = viewModelScope.launch { + val currentJob = viewModelScope.launch { try { val type = if (uiState.value.currentNotificationType == NotificationType.FEED_AND_ROOM) { @@ -110,14 +110,14 @@ class AlarmViewModel @Inject constructor( updateState { it.copy(error = exception.message) } } } finally { - isLoadingData = false - updateState { it.copy(isLoading = false, isLoadingMore = false) } - // 작업 완료 시 job 참조 정리 - if (loadJob?.isCompleted == true) { + if (loadJob == coroutineContext[Job]) { + isLoadingData = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } loadJob = null } } } + loadJob = currentJob } fun loadMoreNotifications() { From c6f40f70b89d6d1937be4c15cee998e0110426bc Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 26 Sep 2025 13:48:14 +0900 Subject: [PATCH 28/29] =?UTF-8?q?[chore]:=20=EC=A3=BC=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/texthip/thip/MainScreen.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/MainScreen.kt b/app/src/main/java/com/texthip/thip/MainScreen.kt index 362922aa..d0239481 100644 --- a/app/src/main/java/com/texthip/thip/MainScreen.kt +++ b/app/src/main/java/com/texthip/thip/MainScreen.kt @@ -43,23 +43,20 @@ fun MainScreen( var feedReselectionTrigger by remember { mutableStateOf(0) } val context = LocalContext.current - // 처리된 알림 ID 추적 (중복 처리 방지) + // 처리된 알림 ID 추적 var processedNotificationId by remember { mutableStateOf(null) } // 푸시 알림에서 온 경우 알림 읽기 API 호출 및 네비게이션 LaunchedEffect(notificationData?.notificationId, notificationData?.fromNotification) { val data = notificationData - // 중복 처리 방지: 이미 처리한 알림이면 스킵 + // 중복 처리 방지 if (data?.notificationId == processedNotificationId) { - Log.d("MainScreen", "Notification already processed: ${data?.notificationId}") return@LaunchedEffect } data?.let { notificationData -> if (notificationData.fromNotification && notificationData.notificationId != null) { - Log.d("MainScreen", "Processing notification: ${notificationData.notificationId}") - try { val entryPoint = EntryPointAccessors.fromApplication( context.applicationContext, @@ -67,7 +64,6 @@ fun MainScreen( ) val notificationRepository = entryPoint.notificationRepository() - // 알림 ID를 Int로 변환 시도 val notificationId = try { notificationData.notificationId.toInt() } catch (e: NumberFormatException) { @@ -79,11 +75,8 @@ fun MainScreen( result.onSuccess { response -> if (response != null) { - Log.d("MainScreen", "Notification check successful, navigating to: ${response.route}") navController.navigateFromNotification(response) - // 알림 상태 강제 새로고침 트리거 notificationRepository.onNotificationReceived() - // 처리 완료 표시 processedNotificationId = notificationData.notificationId } else { Log.w("MainScreen", "Notification check returned null response") From 687c179b1a677d3bc07f5031c79577d1c58d796b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 26 Sep 2025 13:55:25 +0900 Subject: [PATCH 29/29] =?UTF-8?q?[feat]:=20=ED=94=BC=EB=93=9C,=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=ED=99=94=EB=A9=B4=20=EC=9E=AC=EC=A7=84=EC=9E=85?= =?UTF-8?q?=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=ED=99=94=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt | 6 +++++- .../java/com/texthip/thip/ui/group/screen/GroupScreen.kt | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt index 186fcb14..24d30f6e 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt @@ -224,6 +224,7 @@ fun FeedScreen( LaunchedEffect(onFeedTabReselected) { if (onFeedTabReselected > 0) { feedViewModel.refreshOnBottomNavReselect() + alarmViewModel.refreshData() currentListState.scrollToItem(0) } } @@ -285,7 +286,10 @@ fun FeedScreen( }, onChangeFeedLike = feedViewModel::changeFeedLike, onChangeFeedSave = feedViewModel::changeFeedSave, - onPullToRefresh = feedViewModel::pullToRefresh + onPullToRefresh = { + feedViewModel.pullToRefresh() + alarmViewModel.refreshData() + } ) } diff --git a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt index 2cfd8ff1..ee41ae90 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt @@ -64,6 +64,7 @@ fun GroupScreen( // 화면 재진입 시 데이터 새로고침 LaunchedEffect(Unit) { viewModel.resetToInitialState() + alarmViewModel.refreshData() } val uiState by viewModel.uiState.collectAsState() val alarmUiState by alarmViewModel.uiState.collectAsState() @@ -79,7 +80,10 @@ fun GroupScreen( onNavigateToGroupRecruit = onNavigateToGroupRecruit, onNavigateToGroupRoom = onNavigateToGroupRoom, onNavigateToGroupSearchAllRooms = onNavigateToGroupSearchAllRooms, - onRefreshGroupData = { viewModel.refreshGroupData() }, + onRefreshGroupData = { + viewModel.refreshGroupData() + alarmViewModel.refreshData() + }, onCardVisible = { cardIndex -> viewModel.loadMoreGroups() }, onSelectGenre = { genreIndex -> viewModel.selectGenre(genreIndex) }, onHideToast = { viewModel.hideToast() },